Add support for Factory.ai droid agent.

See: https://factory.ai/product/cli
This commit is contained in:
VVX7
2026-01-22 14:04:40 -05:00
parent 7749fa5251
commit 43177df7bf
23 changed files with 948 additions and 61 deletions

590
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,21 +41,29 @@ describe('parsers/index', () => {
expect(hasOutputParser('codex')).toBe(true);
});
it('should register exactly 3 parsers', () => {
it('should register Factory Droid parser', () => {
expect(hasOutputParser('factory-droid')).toBe(false);
initializeOutputParsers();
expect(hasOutputParser('factory-droid')).toBe(true);
});
it('should register exactly 4 parsers', () => {
initializeOutputParsers();
const parsers = getAllOutputParsers();
expect(parsers.length).toBe(3);
expect(parsers.length).toBe(4); // Claude, OpenCode, Codex, Factory Droid
});
it('should clear existing parsers before registering', () => {
// First initialization
initializeOutputParsers();
expect(getAllOutputParsers().length).toBe(3);
expect(getAllOutputParsers().length).toBe(4);
// Second initialization should still have exactly 3
// Second initialization should still have exactly 4
initializeOutputParsers();
expect(getAllOutputParsers().length).toBe(3);
expect(getAllOutputParsers().length).toBe(4);
});
});
@@ -65,7 +73,7 @@ describe('parsers/index', () => {
ensureParsersInitialized();
expect(getAllOutputParsers().length).toBe(3);
expect(getAllOutputParsers().length).toBe(4);
});
it('should be idempotent after first call', () => {

View File

@@ -469,6 +469,7 @@ describe('AGENT_ARTIFACTS', () => {
'aider',
'opencode',
'codex',
'factory-droid',
'claude',
'terminal',
];
@@ -622,7 +623,7 @@ describe('buildContextTransferPrompt', () => {
});
it('should work for all agent type combinations', () => {
const agents: ToolType[] = ['claude-code', 'aider', 'opencode', 'codex', 'claude', 'terminal'];
const agents: ToolType[] = ['claude-code', 'aider', 'opencode', 'codex', 'factory-droid', 'claude', 'terminal'];
for (const source of agents) {
for (const target of agents) {

View File

@@ -303,6 +303,34 @@ export const AGENT_CAPABILITIES: Record<string, AgentCapabilities> = {
supportsContextMerge: true, // Can receive merged context via prompts
supportsContextExport: true, // Session storage supports context export
},
/**
* Factory Droid - Enterprise AI coding assistant from Factory
* https://docs.factory.ai/cli
*
* Verified capabilities based on CLI testing (droid exec --help) and session file analysis.
*/
'factory-droid': {
supportsResume: true, // -s, --session-id <id> (requires a prompt) - Verified
supportsReadOnlyMode: true, // Default mode (no --auto flags) - Verified
supportsJsonOutput: true, // -o stream-json - Verified
supportsSessionId: true, // UUID in session filenames - Verified
supportsImageInput: true, // -f, --file flag - Verified
supportsImageInputOnResume: true, // -f works with -s flag - Verified
supportsSlashCommands: false, // Factory uses different command system
supportsSessionStorage: true, // ~/.factory/sessions/ (JSONL files) - Verified
supportsCostTracking: false, // Token counts only in settings.json, no USD cost
supportsUsageStats: true, // tokenUsage in settings.json - Verified
supportsBatchMode: true, // droid exec subcommand - Verified
requiresPromptToStart: true, // Requires prompt argument for exec
supportsStreaming: true, // stream-json format - Verified
supportsResultMessages: true, // Can detect end of conversation
supportsModelSelection: true, // -m, --model flag - Verified
supportsStreamJsonInput: true, // --input-format stream-json - Verified
supportsThinkingDisplay: true, // Emits thinking content in messages - Verified
supportsContextMerge: true, // Can receive merged context via prompts
supportsContextExport: true, // Session files are exportable
},
};
/**

View File

@@ -179,6 +179,93 @@ export const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabi
command: 'aider',
args: [], // Base args (placeholder - to be configured when implemented)
},
{
id: 'factory-droid',
name: 'Factory Droid',
binaryName: 'droid',
command: 'droid',
args: [], // Base args for interactive mode (none)
requiresPty: false, // Batch mode uses child process
// Batch mode: droid exec [options] "prompt"
batchModePrefix: ['exec'],
// Always skip permissions in batch mode (like Claude Code's --dangerously-skip-permissions)
// Maestro requires full access to work properly
batchModeArgs: ['--skip-permissions-unsafe'],
// JSON output for parsing
jsonOutputArgs: ['-o', 'stream-json'],
// Session resume: -s <id> (requires a prompt)
resumeArgs: (sessionId: string) => ['-s', sessionId],
// Read-only mode is DEFAULT in droid exec (no flag needed)
readOnlyArgs: [],
// YOLO mode (same as batchModeArgs, kept for explicit yoloMode requests)
yoloModeArgs: ['--skip-permissions-unsafe'],
// Model selection is handled by configOptions.model.argBuilder below
// Don't define modelArgs here to avoid duplicate -m flags
// Working directory
workingDirArgs: (dir: string) => ['--cwd', dir],
// File/image input
imageArgs: (imagePath: string) => ['-f', imagePath],
// Prompt is positional argument (no separator needed)
noPromptSeparator: true,
// Default env vars - don't set NO_COLOR as it conflicts with FORCE_COLOR
defaultEnvVars: {},
// UI config options
// Model IDs from droid CLI (exact IDs required)
// NOTE: autonomyLevel is NOT configurable - Maestro always uses --skip-permissions-unsafe
// which conflicts with --auto. This matches Claude Code's behavior.
configOptions: [
{
key: 'model',
type: 'select',
label: 'Model',
description: 'Model to use for Factory Droid',
// Model IDs from `droid exec --help` (2026-01-22)
options: [
'', // Empty = use droid's default (claude-opus-4-5-20251101)
// OpenAI models
'gpt-5.1',
'gpt-5.1-codex',
'gpt-5.1-codex-max',
'gpt-5.2',
// Claude models
'claude-sonnet-4-5-20250929',
'claude-opus-4-5-20251101',
'claude-haiku-4-5-20251001',
// Google models
'gemini-3-pro-preview',
],
default: '', // Empty = use droid's default (claude-opus-4-5-20251101)
argBuilder: (value: string) => (value && value.trim() ? ['-m', value.trim()] : []),
},
{
key: 'reasoningEffort',
type: 'select',
label: 'Reasoning Effort',
description: 'How much the model should reason before responding',
options: ['', 'low', 'medium', 'high'],
default: '', // Empty = use droid's default reasoning
argBuilder: (value: string) => (value && value.trim() ? ['-r', value.trim()] : []),
},
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description: 'Maximum context window in tokens (for UI display)',
default: 200000,
},
],
},
];
export class AgentDetector {
@@ -544,6 +631,16 @@ export class AgentDetector {
path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts', 'aider.exe'),
],
droid: [
// Factory Droid installation paths
path.join(home, '.factory', 'bin', 'droid.exe'),
path.join(localAppData, 'Factory', 'droid.exe'),
path.join(appData, 'Factory', 'droid.exe'),
path.join(home, '.local', 'bin', 'droid.exe'),
// npm global installation
path.join(appData, 'npm', 'droid.cmd'),
path.join(localAppData, 'npm', 'droid.cmd'),
],
};
const pathsToCheck = knownPaths[binaryName] || [];
@@ -632,6 +729,15 @@ export class AgentDetector {
// Add paths from Node version managers (in case installed via npm)
...versionManagerPaths.map((p) => path.join(p, 'aider')),
],
droid: [
// Factory Droid installation paths
path.join(home, '.factory', 'bin', 'droid'),
path.join(home, '.local', 'bin', 'droid'),
'/opt/homebrew/bin/droid',
'/usr/local/bin/droid',
// Add paths from Node version managers (in case installed via npm)
...versionManagerPaths.map((p) => path.join(p, 'droid')),
],
};
const pathsToCheck = knownPaths[binaryName] || [];

View File

@@ -22,7 +22,7 @@ const LOG_CONTEXT = '[AgentSessionStorage]';
/**
* Known agent IDs that have session storage support
*/
const KNOWN_AGENT_IDS: ToolType[] = ['claude-code', 'codex', 'opencode'];
const KNOWN_AGENT_IDS: ToolType[] = ['claude-code', 'codex', 'opencode', 'factory-droid'];
/**
* Session origin types - indicates how the session was created

View File

@@ -21,7 +21,7 @@ import type { ModeratorConfig, GroupChatHistoryEntry } from '../../shared/group-
* Valid agent IDs that can be used as moderators.
* Must match available agents from agent-detector.
*/
const VALID_MODERATOR_AGENT_IDS: ToolType[] = ['claude-code', 'codex', 'opencode'];
const VALID_MODERATOR_AGENT_IDS: ToolType[] = ['claude-code', 'codex', 'opencode', 'factory-droid'];
/**
* Bootstrap settings store for custom storage location.

View File

@@ -327,11 +327,26 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
);
// Get all configuration for an agent
// Merges stored config with defaults from agent's configOptions
ipcMain.handle(
'agents:getConfig',
withIpcErrorLogging(handlerOpts('getConfig', CONFIG_LOG_CONTEXT), async (agentId: string) => {
const allConfigs = agentConfigsStore.get('configs', {});
return allConfigs[agentId] || {};
const storedConfig = allConfigs[agentId] || {};
// Get defaults from agent definition's configOptions
const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId);
const defaults: Record<string, unknown> = {};
if (agentDef?.configOptions) {
for (const option of agentDef.configOptions) {
if (option.default !== undefined) {
defaults[option.key] = option.default;
}
}
}
// Merge: stored config takes precedence over defaults
return { ...defaults, ...storedConfig };
})
);
@@ -351,6 +366,7 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
);
// Get a specific configuration value for an agent
// Falls back to default from agent's configOptions if not stored
ipcMain.handle(
'agents:getConfigValue',
withIpcErrorLogging(
@@ -358,7 +374,16 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
async (agentId: string, key: string) => {
const allConfigs = agentConfigsStore.get('configs', {});
const agentConfig = allConfigs[agentId] || {};
return agentConfig[key];
// Return stored value if present
if (agentConfig[key] !== undefined) {
return agentConfig[key];
}
// Fall back to default from agent definition
const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId);
const option = agentDef?.configOptions?.find((o) => o.key === key);
return option?.default;
}
)
);

View File

@@ -42,6 +42,7 @@ const VALID_TOOL_TYPES: ToolType[] = [
'terminal',
'claude',
'aider',
'factory-droid',
];
/**

View File

@@ -30,6 +30,7 @@ const VALID_TOOL_TYPES = new Set<string>([
'opencode',
'codex',
'terminal',
'factory-droid',
]);
/**
@@ -543,6 +544,148 @@ export const CODEX_ERROR_PATTERNS: AgentErrorPatterns = {
],
};
// ============================================================================
// Factory Droid Error Patterns
// ============================================================================
export const FACTORY_DROID_ERROR_PATTERNS: AgentErrorPatterns = {
auth_expired: [
{
pattern: /invalid.*api.*key/i,
message: 'Invalid API key. Please check your Factory credentials.',
recoverable: true,
},
{
pattern: /authentication.*failed/i,
message: 'Authentication failed. Please verify your Factory API key.',
recoverable: true,
},
{
pattern: /unauthorized/i,
message: 'Unauthorized access. Please check your Factory API key.',
recoverable: true,
},
{
pattern: /FACTORY_API_KEY/i,
message: 'Factory API key not set. Please set FACTORY_API_KEY environment variable.',
recoverable: true,
},
{
pattern: /api.*key.*expired/i,
message: 'Your API key has expired. Please renew your Factory credentials.',
recoverable: true,
},
],
token_exhaustion: [
{
pattern: /context.*exceeded/i,
message: 'Context limit exceeded. Start a new session.',
recoverable: true,
},
{
pattern: /maximum.*tokens/i,
message: 'Maximum token limit reached. Start a new session.',
recoverable: true,
},
{
pattern: /token.*limit/i,
message: 'Token limit reached. Consider starting a fresh conversation.',
recoverable: true,
},
{
pattern: /prompt.*too\s+long/i,
message: 'Prompt is too long. Try a shorter message or start a new session.',
recoverable: true,
},
],
rate_limited: [
{
pattern: /rate.*limit/i,
message: 'Rate limit exceeded. Please wait before trying again.',
recoverable: true,
},
{
pattern: /too many requests/i,
message: 'Too many requests. Please wait before sending more messages.',
recoverable: true,
},
{
pattern: /quota.*exceeded/i,
message: 'Your API quota has been exceeded.',
recoverable: false,
},
{
pattern: /\b429\b/,
message: 'Rate limited. Please wait and try again.',
recoverable: true,
},
],
network_error: [
{
pattern: /connection\s*(failed|refused|error|reset|closed)/i,
message: 'Connection failed. Check your internet connection.',
recoverable: true,
},
{
pattern: /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND/i,
message: 'Network error. Check your internet connection.',
recoverable: true,
},
{
pattern: /request\s+timed?\s*out|timed?\s*out\s+waiting/i,
message: 'Request timed out. Please try again.',
recoverable: true,
},
{
pattern: /network\s+(error|failure|unavailable)/i,
message: 'Network error occurred. Please check your connection.',
recoverable: true,
},
],
permission_denied: [
{
pattern: /permission denied/i,
message: 'Permission denied. The agent cannot access the requested resource.',
recoverable: false,
},
{
pattern: /access denied/i,
message: 'Access denied to the requested resource.',
recoverable: false,
},
{
pattern: /autonomy.*level/i,
message: 'Operation requires higher autonomy level. Use --auto flag.',
recoverable: true,
},
],
agent_crashed: [
{
pattern: /\b(fatal|unexpected|internal|unhandled)\s+error\b/i,
message: 'An unexpected error occurred in the agent.',
recoverable: true,
},
],
session_not_found: [
{
pattern: /session.*not found/i,
message: 'Session not found. Starting fresh conversation.',
recoverable: true,
},
{
pattern: /invalid.*session/i,
message: 'Invalid session. Starting fresh conversation.',
recoverable: true,
},
],
};
// ============================================================================
// SSH Error Patterns
// ============================================================================
@@ -724,6 +867,7 @@ const patternRegistry = new Map<ToolType, AgentErrorPatterns>([
['claude-code', CLAUDE_ERROR_PATTERNS],
['opencode', OPENCODE_ERROR_PATTERNS],
['codex', CODEX_ERROR_PATTERNS],
['factory-droid', FACTORY_DROID_ERROR_PATTERNS],
]);
/**

View File

@@ -53,6 +53,7 @@ export {
import { ClaudeOutputParser } from './claude-output-parser';
import { OpenCodeOutputParser } from './opencode-output-parser';
import { CodexOutputParser } from './codex-output-parser';
import { FactoryDroidOutputParser } from './factory-droid-output-parser';
import {
registerOutputParser,
clearParserRegistry,
@@ -64,6 +65,7 @@ import { logger } from '../utils/logger';
export { ClaudeOutputParser } from './claude-output-parser';
export { OpenCodeOutputParser } from './opencode-output-parser';
export { CodexOutputParser } from './codex-output-parser';
export { FactoryDroidOutputParser } from './factory-droid-output-parser';
const LOG_CONTEXT = '[OutputParsers]';
@@ -79,6 +81,7 @@ export function initializeOutputParsers(): void {
registerOutputParser(new ClaudeOutputParser());
registerOutputParser(new OpenCodeOutputParser());
registerOutputParser(new CodexOutputParser());
registerOutputParser(new FactoryDroidOutputParser());
// Log registered parsers for debugging
const registeredParsers = getAllOutputParsers().map((p) => p.agentId);

View File

@@ -48,6 +48,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Record<ToolType, number> = {
opencode: 128000, // OpenCode (depends on model, 128k is conservative default)
aider: 128000, // Aider (varies by model, 128k is conservative default)
terminal: 0, // Terminal has no context window
'factory-droid': 200000, // Factory Droid (Claude Opus 4.5 default context)
};
/**

View File

@@ -95,6 +95,17 @@ export class ExitHandler {
this.handleBatchModeExit(sessionId, managedProcess);
}
// Handle stream-json mode: emit accumulated streamed text if no result was emitted
// Some agents (like Factory Droid) don't send explicit "done" events, they just exit
if (isStreamJsonMode && !managedProcess.resultEmitted && managedProcess.streamedText) {
managedProcess.resultEmitted = true;
logger.debug('[ProcessManager] Emitting streamed text at exit (no result event)', 'ProcessManager', {
sessionId,
streamedTextLength: managedProcess.streamedText.length,
});
this.bufferManager.emitDataBuffered(sessionId, managedProcess.streamedText);
}
// Check for errors using the parser (if not already emitted)
if (outputParser && !managedProcess.errorEmitted) {
const agentError = outputParser.detectErrorFromExit(

View File

@@ -8,12 +8,14 @@
export { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session-storage';
export { OpenCodeSessionStorage } from './opencode-session-storage';
export { CodexSessionStorage } from './codex-session-storage';
export { FactoryDroidSessionStorage } from './factory-droid-session-storage';
import Store from 'electron-store';
import { registerSessionStorage } from '../agent-session-storage';
import { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session-storage';
import { OpenCodeSessionStorage } from './opencode-session-storage';
import { CodexSessionStorage } from './codex-session-storage';
import { FactoryDroidSessionStorage } from './factory-droid-session-storage';
/**
* Options for initializing session storages
@@ -33,4 +35,5 @@ export function initializeSessionStorages(options?: InitializeSessionStoragesOpt
registerSessionStorage(new ClaudeSessionStorage(options?.claudeSessionOriginsStore));
registerSessionStorage(new OpenCodeSessionStorage());
registerSessionStorage(new CodexSessionStorage());
registerSessionStorage(new FactoryDroidSessionStorage());
}

View File

@@ -73,7 +73,7 @@ interface EditAgentModalProps {
}
// Supported agents that are fully implemented
const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex'];
const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex', 'factory-droid'];
export function NewInstanceModal({
isOpen,

View File

@@ -63,6 +63,13 @@ export const AGENT_TILES: AgentTile[] = [
description: 'Open-source AI coding assistant',
brandColor: '#F97316', // Orange
},
{
id: 'factory-droid',
name: 'Factory Droid',
supported: true,
description: "Factory's AI coding assistant",
brandColor: '#8B5CF6', // Purple/violet
},
// Coming soon agents at the bottom
{
id: 'aider',
@@ -87,9 +94,9 @@ export const AGENT_TILES: AgentTile[] = [
},
];
// Grid dimensions for keyboard navigation (3 cols for 5 items)
// Grid dimensions for keyboard navigation (3 cols for 7 items)
const GRID_COLS = 3;
const GRID_ROWS = 2;
const GRID_ROWS = 3;
/**
* Get SVG logo for an agent with brand colors

View File

@@ -626,6 +626,28 @@ export function AgentConfigPanel({
</span>
</label>
)}
{option.type === 'select' && option.options && (
<select
value={agentConfig[option.key] ?? option.default ?? ''}
onChange={(e) => {
onConfigChange(option.key, e.target.value);
onConfigBlur();
}}
onClick={(e) => e.stopPropagation()}
className="w-full p-2 rounded border bg-transparent outline-none text-xs cursor-pointer"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
backgroundColor: theme.colors.bgMain,
}}
>
{option.options.map((opt) => (
<option key={opt} value={opt} style={{ backgroundColor: theme.colors.bgMain }}>
{opt}
</option>
))}
</select>
)}
<p className="text-xs opacity-50 mt-2">{option.description}</p>
</div>
))}

View File

@@ -43,6 +43,9 @@ export const AGENT_ICONS: Record<string, string> = {
opencode: '📟',
aider: '🛠️',
// Enterprise
'factory-droid': '🏭',
// Terminal/shell (internal)
terminal: '💻',
};

View File

@@ -666,14 +666,15 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
? `${activeSession.id}-ai-${activeTabForSpawn?.id || 'default'}`
: `${activeSession.id}-terminal`;
// Check if this is an AI agent in batch mode (e.g., Claude Code, OpenCode, Codex)
// Check if this is an AI agent in batch mode (e.g., Claude Code, OpenCode, Codex, Factory Droid)
// Batch mode agents spawn a new process per message rather than writing to stdin
const isBatchModeAgent =
currentMode === 'ai' &&
(activeSession.toolType === 'claude' ||
activeSession.toolType === 'claude-code' ||
activeSession.toolType === 'opencode' ||
activeSession.toolType === 'codex');
activeSession.toolType === 'codex' ||
activeSession.toolType === 'factory-droid');
if (isBatchModeAgent) {
// Batch mode: Spawn new agent process with prompt

View File

@@ -110,6 +110,18 @@ export const AGENT_ARTIFACTS: Record<ToolType, string[]> = {
'openai codex',
'OpenAI Codex',
],
'factory-droid': [
// Brand references
'Factory',
'Droid',
'Factory Droid',
// Model references (can use multiple providers)
'Claude',
'GPT',
'Gemini',
'Opus',
'Sonnet',
],
claude: [
// This is the base Claude (not Claude Code)
'Claude',
@@ -150,6 +162,12 @@ export const AGENT_TARGET_NOTES: Record<ToolType, string> = {
It uses reasoning models like o1, o3, and o4-mini.
It can read files, edit code, and run terminal commands.
It excels at complex reasoning and problem-solving.
`,
'factory-droid': `
Factory Droid is an enterprise AI coding assistant by Factory.
It supports multiple model providers (Claude, GPT, Gemini).
It can read and edit files, run commands, search code, and interact with git.
It has tiered autonomy levels for controlling operation permissions.
`,
claude: `
Claude is a general-purpose AI assistant by Anthropic.
@@ -171,6 +189,7 @@ export function getAgentDisplayName(agentType: ToolType): string {
aider: 'Aider',
opencode: 'OpenCode',
codex: 'OpenAI Codex',
'factory-droid': 'Factory Droid',
claude: 'Claude',
terminal: 'Terminal',
};

View File

@@ -18,6 +18,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Record<ToolType, number> = {
codex: 200000, // OpenAI o3/o4-mini context window
opencode: 128000, // OpenCode (depends on model, 128k is conservative default)
aider: 128000, // Aider (varies by model, 128k is conservative default)
'factory-droid': 200000, // Factory Droid (varies by model, defaults to Claude Opus)
terminal: 0, // Terminal has no context window
};

View File

@@ -114,6 +114,7 @@ export function getProviderDisplayName(toolType: ToolType): string {
aider: 'Aider',
opencode: 'OpenCode',
codex: 'Codex',
'factory-droid': 'Factory Droid',
terminal: 'Terminal',
};
return displayNames[toolType] || toolType;

View File

@@ -1,7 +1,7 @@
// Shared type definitions for Maestro CLI and Electron app
// These types are used by both the CLI tool and the renderer process
export type ToolType = 'claude' | 'claude-code' | 'aider' | 'opencode' | 'codex' | 'terminal';
export type ToolType = 'claude' | 'claude-code' | 'aider' | 'opencode' | 'codex' | 'terminal' | 'factory-droid';
// Session group
export interface Group {