+ Code mode lets the assistant delegate coding tasks
+ to Claude Code or Codex running
+ on your machine. Pick the agent inline from the composer; the assistant calls it via
+ acpx
+ and streams results back into chat.
+
+
+ Requires an active Claude Code subscription or
+ a ChatGPT/Codex subscription. You can have one or both.
+
+
+
+
+
+ Agent status
+
+
+
+
+
+
+
+
+
+
+
Enable code mode
+
+ Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
+
+
+
+
+
+ {enabled && status && !anyReady && (
+
+
+
+ Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
+ account, then click Re-check.
+
Loading...
diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts
index 41344107..bbf1cde2 100644
--- a/apps/x/apps/renderer/src/lib/chat-conversation.ts
+++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts
@@ -517,9 +517,41 @@ const TOOL_DISPLAY_NAMES: Record = {
* 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 | 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
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index f0d867bd..3146101e 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -392,9 +392,10 @@ export async function mapAgentTool(t: z.infer): Promise 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 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 -s ${sessionName} ""
+\`\`\`
+
+**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 -s ${sessionName} ""
+\`\`\`
+
+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 \`\` 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: [],
});
}
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index b0d1bcbb..b3611fe4 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -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 {
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('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
diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts
index c2879228..d8e81a58 100644
--- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts
+++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts
@@ -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 claude exec ""
-` + "`" + `
+> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
-**For Codex:**
-` + "`" + `
-npx acpx@latest --approve-all --cwd codex exec ""
-` + "`" + `
+…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 \`\` (\`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 \`\` — **stable for this whole chat**:
+- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-\`), 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 sessions ensure --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 -s ""
+\`\`\`
+
+**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 -s ""
+\`\`\`
+
+**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 \` and \`-s \` come AFTER the agent name:
-` + "`" + `
-npx acpx@latest [global flags] exec ""
-` + "`" + `
+- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd -s ""\`
+- ✗ Wrong: \`npx acpx@latest --approve-all -s "..."\` (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 doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd sessions ensure --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;
diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts
index e3622b01..9bfb4250 100644
--- a/apps/x/packages/core/src/application/lib/builtin-tools.ts
+++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts
@@ -95,21 +95,47 @@ const LLMPARSE_MIME_TYPES: Record = {
// 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();
+ 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: \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 = {
}
// 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,
diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts
index 150c8f72..6be3c0e6 100644
--- a/apps/x/packages/core/src/application/lib/command-executor.ts
+++ b/apps/x/packages/core/src/application/lib/command-executor.ts
@@ -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 {
try {
@@ -89,6 +90,7 @@ export async function executeCommand(
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell,
+ env: options?.env,
});
return {
diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts
index b3d2affa..a513b3ac 100644
--- a/apps/x/packages/core/src/application/lib/message-queue.ts
+++ b/apps/x/packages/core/src/application/lib/message-queue.ts
@@ -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;
+ enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise;
dequeue(runId: string): Promise;
}
@@ -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 {
+ async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise {
if (!this.store[runId]) {
this.store[runId] = [];
}
@@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceInput,
voiceOutput,
searchEnabled,
+ codeMode,
middlePaneContext,
});
return id;
diff --git a/apps/x/packages/core/src/code-mode/index.ts b/apps/x/packages/core/src/code-mode/index.ts
new file mode 100644
index 00000000..bdf2eecb
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/index.ts
@@ -0,0 +1,3 @@
+export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
+export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
+export { checkCodeModeAgentStatus } from './status.js';
diff --git a/apps/x/packages/core/src/code-mode/repo.ts b/apps/x/packages/core/src/code-mode/repo.ts
new file mode 100644
index 00000000..dd318b34
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/repo.ts
@@ -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;
+ setConfig(config: CodeModeConfig): Promise;
+}
+
+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 {
+ 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 {
+ try {
+ const content = await fs.readFile(this.configPath, 'utf8');
+ return CodeModeConfig.parse(JSON.parse(content));
+ } catch {
+ return this.defaultConfig;
+ }
+ }
+
+ async setConfig(config: CodeModeConfig): Promise {
+ const validated = CodeModeConfig.parse(config);
+ await fs.mkdir(path.dirname(this.configPath), { recursive: true });
+ await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
+ }
+}
diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts
new file mode 100644
index 00000000..3858708b
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/status.ts
@@ -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 {
+ 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 {
+ 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 | 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 : 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;
+
+ const oauth = parsed.claudeAiOauth as Record | 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 {
+ 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 {
+ 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 {
+ 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;
+
+ if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
+
+ const tokens = parsed.tokens as Record | 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 {
+ 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 },
+ };
+}
diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts
new file mode 100644
index 00000000..57a3158f
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/types.ts
@@ -0,0 +1,18 @@
+import z from "zod";
+
+export const CodeModeConfig = z.object({
+ enabled: z.boolean(),
+});
+export type CodeModeConfig = z.infer;
+
+export const AgentStatus = z.object({
+ installed: z.boolean(),
+ signedIn: z.boolean(),
+});
+export type AgentStatus = z.infer;
+
+export const CodeModeAgentStatus = z.object({
+ claude: AgentStatus,
+ codex: AgentStatus,
+});
+export type CodeModeAgentStatus = z.infer;
diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts
index 9382de8b..f452105a 100644
--- a/apps/x/packages/core/src/di/container.ts
+++ b/apps/x/packages/core/src/di/container.ts
@@ -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(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(),
+ codeModeConfigRepo: asClass(FSCodeModeConfigRepo).singleton(),
agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass(FSSlackConfigRepo).singleton(),
diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts
index e10bf575..c316cd3b 100644
--- a/apps/x/packages/core/src/runs/runs.ts
+++ b/apps/x/packages/core/src/runs/runs.ts
@@ -39,9 +39,9 @@ export async function createRun(opts: z.infer): Promise
return run;
}
-export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise {
+export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise {
const queue = container.resolve('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('agentRuntime');
runtime.trigger(runId);
return id;
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index d0cee9ca..d42e9935 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -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(),
diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts
index 5ea0d667..a977db0b 100644
--- a/apps/x/packages/shared/src/runs.ts
+++ b/apps/x/packages/shared/src/runs.ts
@@ -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({