Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/content/docs/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ Not all providers support the same features. Here's what varies:

**Initial prompt**: Pass instructions when starting the agent. Most providers support this.

**Model selection** _(Claude Code only)_: Choose the Claude model to use per task. The list is fetched live from the Anthropic API.

**Effort level** _(Claude Code only)_: Set reasoning depth at startup — low, medium, high, or max.

**Fast mode** _(Claude Code, Opus models only)_: Enable fast streaming output.

## Adding a Provider

Providers are detected automatically when you install them. Open Emdash, go to Settings, and click "Refresh" to scan for newly installed CLIs.
Expand Down
10 changes: 10 additions & 0 deletions docs/content/docs/tasks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ Each agent gets its own worktree. Compare approaches and pick the best result. S

If tasks start local servers, use `EMDASH_PORT` in your Project config script fields to avoid port collisions between parallel tasks. See [Project Configuration](/project-config).

## Claude Options

When Claude Code is selected, a **Claude options** section appears. Expand it to configure:

- **Model**: Choose a specific Claude model (e.g. `claude-opus-4-6`, `claude-sonnet-4-6[1m]`). Models are fetched from the Anthropic API when an `ANTHROPIC_API_KEY` is set, with a hardcoded fallback list otherwise. Defaults to whatever Claude Code uses by default.
- **Effort**: Control reasoning depth — `low`, `medium`, `high`, or `max`. Leave at Default to let Claude decide.
- **Fast mode**: Enable Claude's fast streaming mode. Only shown when an Opus model is selected, as fast mode is currently supported for Opus models only.

These options are also available in the **Add Agent to Task** dialog.

## Advanced Options

Click **Advanced Options** when creating a task to access:
Expand Down
143 changes: 143 additions & 0 deletions src/main/ipc/connectionsIpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ipcMain } from 'electron';
import https from 'https';
import { connectionsService } from '../services/ConnectionsService';
import {
getProviderCustomConfig,
Expand All @@ -7,6 +8,110 @@ import {
type ProviderCustomConfig,
} from '../settings';

interface ClaudeModel {
id: string;
name: string;
/**
* Whether this model supports Claude Code's fast mode (--settings '{"fastMode":true}').
* Derived from the model ID — currently only Opus models support it.
* Not returned by the Anthropic API; computed locally.
*/
supportsFast: boolean;
}

/**
* Fast mode is documented for Opus models only.
* The Anthropic API does not expose this capability, so we infer it from the model ID.
*/
function claudeModelSupportsFast(modelId: string): boolean {
return modelId.toLowerCase().includes('opus');
}

/**
* Hardcoded fallback list used when the API is unavailable.
*
* Note: `claude-sonnet-4-6[1m]` is the canonical model ID returned by the
* Anthropic API for the 1M-context Sonnet variant. The square brackets are
* intentional and are safely handled at the PTY layer via quoteShellArg.
*/
const CLAUDE_FALLBACK_MODELS: ClaudeModel[] = [
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6', supportsFast: true },
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', supportsFast: false },
{ id: 'claude-sonnet-4-6[1m]', name: 'Claude Sonnet 4.6 (1M context)', supportsFast: false },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', supportsFast: false },
];

/** In-memory cache so repeated opens of the modal don't hit the API every time. */
interface ModelCache {
models: ClaudeModel[];
expiresAt: number;
}
let claudeModelCache: ModelCache | null = null;
let claudeModelCacheGeneration = 0;
const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour

/** Fetch available models from the Anthropic API. Returns null when unavailable. */
async function fetchAnthropicModels(): Promise<ClaudeModel[] | null> {
// Prefer a key saved in the provider custom config (e.g. via Settings → Providers)
// so users who configure Claude that way also get live model lists.
const configuredKey = getProviderCustomConfig('claude')?.env?.['ANTHROPIC_API_KEY'];
const apiKey =
(typeof configuredKey === 'string' && configuredKey.trim() ? configuredKey.trim() : null) ??
process.env.ANTHROPIC_API_KEY;
if (!apiKey) return null;

return new Promise((resolve) => {
const req = https.request(
{
hostname: 'api.anthropic.com',
path: '/v1/models',
method: 'GET',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
timeout: 5000,
},
(res) => {
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
res.resume(); // drain body so the socket can be reused
resolve(null);
return;
}
let data = '';
res.on('data', (chunk: Buffer) => (data += chunk.toString()));
res.on('end', () => {
try {
const parsed = JSON.parse(data) as {
data?: Array<{ id: string; display_name?: string }>;
};
if (!Array.isArray(parsed?.data)) {
resolve(null);
return;
}
const models: ClaudeModel[] = parsed.data
.filter((m) => typeof m.id === 'string' && m.id.startsWith('claude-'))
.map((m) => ({
id: m.id,
name: m.display_name || m.id,
supportsFast: claudeModelSupportsFast(m.id),
}));
resolve(models.length > 0 ? models : null);
} catch {
resolve(null);
}
});
}
);
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
req.end();
});
}

export function registerConnectionsIpc() {
ipcMain.handle(
'providers:getStatuses',
Expand Down Expand Up @@ -65,12 +170,50 @@ export function registerConnectionsIpc() {
}
});

// List available models for a provider (currently only 'claude' is supported)
ipcMain.handle('providers:listModels', async (_event, providerId: string) => {
if (providerId !== 'claude') {
return { success: true, models: [] };
}

// Return fresh cache when available
if (claudeModelCache && Date.now() < claudeModelCache.expiresAt) {
return { success: true, models: claudeModelCache.models };
}

// Snapshot the generation before the async fetch. If the config is updated
// while we're awaiting, the generation will have changed and we discard the
// result to avoid repopulating the cache with stale models.
const generation = claudeModelCacheGeneration;
try {
const fetched = await fetchAnthropicModels();
if (fetched && generation === claudeModelCacheGeneration) {
claudeModelCache = { models: fetched, expiresAt: Date.now() + MODEL_CACHE_TTL_MS };
return { success: true, models: fetched };
}
} catch {
// fall through
}

// API unavailable — return stale cache if we have one, otherwise use hardcoded fallback
if (claudeModelCache) {
return { success: true, models: claudeModelCache.models };
}
return { success: true, models: CLAUDE_FALLBACK_MODELS };
});

// Update custom config for a specific provider
ipcMain.handle(
'providers:updateCustomConfig',
(_event, providerId: string, config: ProviderCustomConfig | undefined) => {
try {
updateProviderCustomConfig(providerId, config);
// Bust the model cache when the Claude provider config changes so an
// updated ANTHROPIC_API_KEY is picked up immediately on the next fetch.
if (providerId === 'claude') {
claudeModelCache = null;
claudeModelCacheGeneration += 1;
}
return { success: true };
} catch (error) {
return {
Expand Down
5 changes: 5 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
clickTime?: number;
env?: Record<string, string>;
resume?: boolean;
model?: string;
effort?: string;
fastMode?: boolean;
}) => ipcRenderer.invoke('pty:startDirect', opts),

ptyScpToRemote: (args: { connectionId: string; localPaths: string[] }) =>
Expand Down Expand Up @@ -636,6 +639,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAllProviderCustomConfigs: () => ipcRenderer.invoke('providers:getAllCustomConfigs'),
updateProviderCustomConfig: (providerId: string, config: any) =>
ipcRenderer.invoke('providers:updateCustomConfig', providerId, config),
listProviderModels: (providerId: string) =>
ipcRenderer.invoke('providers:listModels', providerId),

// Debug helpers
debugAppendLog: (filePath: string, content: string, options?: { reset?: boolean }) =>
Expand Down
43 changes: 40 additions & 3 deletions src/main/services/ptyIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,11 @@ function buildRemoteProviderInvocation(args: {
autoApprove?: boolean;
initialPrompt?: string;
resume?: boolean;
model?: string;
effort?: string;
fastMode?: boolean;
}): { cli: string; cmd: string; installCommand?: string } {
const { providerId, autoApprove, initialPrompt, resume } = args;
const { providerId, autoApprove, initialPrompt, resume, model, effort, fastMode } = args;
const fallbackProvider = getProvider(providerId as ProviderId);
const resolvedConfig = resolveProviderCommandConfig(providerId);
const provider = resolvedConfig?.provider ?? fallbackProvider;
Expand All @@ -522,6 +525,15 @@ function buildRemoteProviderInvocation(args: {
useKeystrokeInjection: provider?.useKeystrokeInjection,
});
cliArgs.push(...getProviderRuntimeCliArgs({ providerId, target: 'remote' }));
if (model && providerId === 'claude') {
cliArgs.push('--model', model);
}
if (effort && providerId === 'claude') {
cliArgs.push('--effort', effort);
}
if (fastMode && providerId === 'claude') {
cliArgs.push('--settings', '{"fastMode":true}');
}

const cmdParts = [...cliCommandParts, ...cliArgs];
const cmd = cmdParts.map(quoteShellArg).join(' ');
Expand Down Expand Up @@ -1172,15 +1184,31 @@ export function registerPtyIpc(): void {
initialPrompt?: string;
env?: Record<string, string>;
resume?: boolean;
model?: string;
effort?: string;
fastMode?: boolean;
}
) => {
if (process.env.EMDASH_DISABLE_PTY === '1') {
return { ok: false, error: 'PTY disabled via EMDASH_DISABLE_PTY=1' };
}

try {
const { id, providerId, cwd, remote, cols, rows, autoApprove, initialPrompt, env, resume } =
args;
const {
id,
providerId,
cwd,
remote,
cols,
rows,
autoApprove,
initialPrompt,
env,
resume,
model,
effort,
fastMode,
} = args;
const existing = getPty(id);

if (remote?.connectionId) {
Expand All @@ -1204,6 +1232,9 @@ export function registerPtyIpc(): void {
autoApprove,
initialPrompt,
resume,
model,
effort,
fastMode,
});

const resolvedConfig = resolveProviderCommandConfig(providerId);
Expand Down Expand Up @@ -1373,6 +1404,9 @@ export function registerPtyIpc(): void {
env,
resume: effectiveResume,
tmux,
model,
effort,
fastMode,
});

// Fall back to shell-based spawn when direct spawn is unavailable or shellSetup/tmux is set
Expand All @@ -1399,6 +1433,9 @@ export function registerPtyIpc(): void {
skipResume: !resume,
shellSetup,
tmux,
model,
effort,
fastMode,
});
usedFallback = true;
}
Expand Down
Loading
Loading