diff --git a/README.md b/README.md index fc1e934b7..6806ddcd8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ countries and as low as $100 a year. ` intact. */ +export function prepareDevinPromptFile(args: string[], cwd: string, agentDir: string): string[] { + const separator = args.lastIndexOf('--'); + if (separator < 0 || separator !== args.length - 2) return args; + + let fileArg = -1; + let sourcePath = ''; + let equalsForm = false; + for (let i = 0; i < separator; i++) { + if (args[i] === '--prompt-file' && i + 1 < separator) { + fileArg = i + 1; + sourcePath = args[i + 1]; + equalsForm = false; + i++; + } else if (args[i].startsWith('--prompt-file=')) { + fileArg = i; + sourcePath = args[i].slice('--prompt-file='.length); + equalsForm = true; + } + } + if (fileArg < 0) return args; + + const expanded = sourcePath === '~' ? homedir() : sourcePath.startsWith('~/') ? join(homedir(), sourcePath.slice(2)) : sourcePath; + const source = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); + const personalPrompt = readFileSync(source, 'utf8'); + const privateDir = join(agentDir, '.devin'); + mkdirSync(privateDir, { recursive: true, mode: 0o700 }); + const combinedPath = join(privateDir, 'initial-prompt.md'); + writeFileSync(combinedPath, `${personalPrompt.trimEnd()}\n\n${args[separator + 1]}\n`, { mode: 0o600 }); + + const result = args.slice(0, separator); + result[fileArg] = equalsForm ? `--prompt-file=${combinedPath}` : combinedPath; + return result; +} diff --git a/src/main/hive.ts b/src/main/hive.ts index 5f9c38bff..af99d4c46 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -236,7 +236,7 @@ function shortRand(): string { const PROXY_BIND_ATTEMPTS = 3; const PROXY_BIND_BACKOFF_MS = [250, 750]; -const MINE_IGNORE_LINES = ['settings.json', 'cursor.json', 'inbox/', 'outbox/', '.codex/']; +const MINE_IGNORE_LINES = ['settings.json', 'cursor.json', 'inbox/', 'outbox/', '.codex/', '.devin/']; /** Idempotently ensure `/.gitignore` excludes the non-memory files. * Append-only: writes only the missing lines, leaving any existing entries. */ diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d..253a5e686 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -80,6 +80,7 @@ import { type AgentProvider } from '../shared/agentProvider'; import { buildMissingCliScript, chooseInstallRung } from './cliInstall'; +import { prepareDevinPromptFile } from './devinPrompt'; import { detectNodeVersion, nodeIsUsable, resolveNodeInstaller } from './nodeInstall'; import { toolCatalog, type ToolStatus } from '../shared/toolCatalog'; import { listLocalSkills, loadCatalog, installSkill, uninstallSkill, type LocalSkill } from './skills'; @@ -2775,6 +2776,11 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten // seedDelivery:'type-into-tui') rather than passed on argv. Surfaced in the spawn // result so the renderer types it through the per-pty write-chain. (ondev-b) let seedPrompt: string | undefined; + // Devin uses `-- `; all CLI options must precede that + // separator. Main-only hires also need the same auto-mode posture as GUI hires. + if (provider === 'devin') { + opts.args = argsWithAutoModeFlag(opts.args ?? [], readConfig().autoMode, provider); + } if (opts.hive && hive.enabled()) { try { const inj = await hive.ensureAgent( @@ -2796,7 +2802,10 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten extraWritableDirs: [memory.env().MEMPALACE_PALACE_PATH].filter((p): p is string => !!p) } ); - opts.args = [...(opts.args ?? []), ...inj.args]; + const injectedArgs = [...(opts.args ?? []), ...inj.args]; + opts.args = provider === 'devin' + ? prepareDevinPromptFile(injectedArgs, opts.cwd, inj.env.AGENT_DIR) + : injectedArgs; seedPrompt = inj.seedPrompt; // A degraded spawn (proxy bridge never bound) is told to the user the same // way breaker escalations are: a native toast, gated on the notifications @@ -2905,7 +2914,13 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten const sid = typedSid || (opts.resume === true ? hive.lastSession(opts.hive.id) : undefined); if (sid && rf) { const args = opts.args ?? []; - if (!args.includes(rf)) { args.push(rf, sid); opts.args = args; didResume = true; } + if (!args.includes(rf)) { + const promptSeparator = preset.initialPromptFlag === '--' ? args.indexOf('--') : -1; + if (promptSeparator >= 0) args.splice(promptSeparator, 0, rf, sid); + else args.push(rf, sid); + opts.args = args; + didResume = true; + } } else if (sid && rsub) { // Subcommand form (Codex): `codex resume [OPTIONS] [SESSION_ID]` — the // subcommand MUST be argv[0], the id trails the flags. Codex indexes diff --git a/src/main/memory.ts b/src/main/memory.ts index d1be74e1d..4377c4e26 100644 --- a/src/main/memory.ts +++ b/src/main/memory.ts @@ -30,7 +30,7 @@ import { quarantineDirsToReap, quarantineStampMs, nextMineDelayMs } from './pala * that are not currently running. See hive.ts for why `.codex/` matters beyond * mempalace: it is also what stopped the hive's git repo from versioning every * Codex transcript and sqlite log into a 7.5GB history. */ -const MINE_IGNORE_LINES = ['settings.json', 'cursor.json', 'inbox/', 'outbox/', '.codex/']; +const MINE_IGNORE_LINES = ['settings.json', 'cursor.json', 'inbox/', 'outbox/', '.codex/', '.devin/']; /** Idempotently ensure `/.gitignore` excludes the non-memory files. * Writes only the missing lines (append-only) so it's safe to call every cycle. */ diff --git a/src/main/realtimeActions.ts b/src/main/realtimeActions.ts index 21ebd3e81..d9f473060 100644 --- a/src/main/realtimeActions.ts +++ b/src/main/realtimeActions.ts @@ -164,7 +164,7 @@ const PENDING_TTL_MS = 120_000; const PROVIDER_COMMAND: Record = { claude: 'claude', codex: 'codex', antigravity: 'antigravity', gemini: 'gemini', opencode: 'opencode', crush: 'crush', pi: 'pi', qwen: 'qwen', copilot: 'copilot', - cursor: 'cursor-agent' + cursor: 'cursor-agent', devin: 'devin' }; /** Bare affirmations that must NEVER authorize a destructive op on their own — diff --git a/src/renderer/src/components/AddAgentModal.tsx b/src/renderer/src/components/AddAgentModal.tsx index 7545c66ea..de2200b7a 100644 --- a/src/renderer/src/components/AddAgentModal.tsx +++ b/src/renderer/src/components/AddAgentModal.tsx @@ -1020,6 +1020,8 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal ? 'agy' : provider === 'codex' ? 'codex' + : provider === 'devin' + ? 'devin' : provider === 'custom' ? 'your-agent-cli' : 'claude' diff --git a/src/renderer/src/components/OnboardingWizard.tsx b/src/renderer/src/components/OnboardingWizard.tsx index 4438ee0eb..88d9312e6 100644 --- a/src/renderer/src/components/OnboardingWizard.tsx +++ b/src/renderer/src/components/OnboardingWizard.tsx @@ -84,7 +84,8 @@ const PROVIDER_BLURB_KEYS: Partial> = { codex: 'onboarding.providerBlurb.codex', antigravity: 'onboarding.providerBlurb.antigravity', qwen: 'onboarding.providerBlurb.qwen', - cursor: 'onboarding.providerBlurb.cursor' + cursor: 'onboarding.providerBlurb.cursor', + devin: 'onboarding.providerBlurb.devin' }; export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 68aac2743..61ec95f11 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -642,7 +642,8 @@ "antigravity": "Antigravity — Google Gemini", "qwen": "Qwen — runs a local Qwen model on your machine", "gemini": "Gemini CLI - Google Gemini", - "cursor": "Cursor Agent CLI — uses your Cursor credits (Luna, Composer, …)" + "cursor": "Cursor Agent CLI — uses your Cursor credits (Luna, Composer, …)", + "devin": "Devin CLI — local agent with SWE-2 models" }, "home": { "descPlain": "Create a new, empty folder for the app to call home. Everything the app remembers — its own settings and your agents' memory — is stored here. Something like ~/HarnessAgents works well. We'll create it for you if it doesn't exist.", diff --git a/src/renderer/src/realtime/actions.ts b/src/renderer/src/realtime/actions.ts index 552fbbe1b..6ebcf2c8a 100644 --- a/src/renderer/src/realtime/actions.ts +++ b/src/renderer/src/realtime/actions.ts @@ -187,7 +187,7 @@ export function realtimeActionTools(): ReturnType[] { parameters: { type: 'object', properties: { - provider: { type: 'string', description: 'Engine: claude (default), codex, gemini, opencode, crush, pi, qwen, copilot, cursor.' }, + provider: { type: 'string', description: 'Engine: claude (default), codex, gemini, opencode, crush, pi, qwen, copilot, cursor, devin.' }, role: { type: 'string', description: 'Optional. The role/job for the new agent.' }, name: { type: 'string', description: 'Optional. A name for the agent.' }, cwd: { type: 'string', description: 'Optional. Working directory; defaults to the hive root.' } diff --git a/src/renderer/src/store/config.ts b/src/renderer/src/store/config.ts index 4f11435af..1bff259cb 100644 --- a/src/renderer/src/store/config.ts +++ b/src/renderer/src/store/config.ts @@ -230,6 +230,8 @@ export type { CatalogModel, ModelCatalog } from '@shared/modelCatalogPayload'; * - cursor: ids match `cursor-agent models` / `--model` (Cursor account catalog). * Luna is the cheap, high-context default for Michael; the rest are curated * quick-picks and the command field stays editable for any live slug. + * - devin: `devin models list` accepts the SWE-2 family slug or its specific + * variants. The command field also accepts the user's `--prompt-file` flag. * - grok: the models reported by the installed Grok CLI (`grok models`). * - kimi: managed Kimi Code aliases accepted by `kimi --model `. * - custom: no presets at all; the command field is the whole interface. diff --git a/src/shared/agentProvider.ts b/src/shared/agentProvider.ts index 0c3e5fb5f..553cb993a 100644 --- a/src/shared/agentProvider.ts +++ b/src/shared/agentProvider.ts @@ -34,6 +34,7 @@ export type AgentProvider = | 'pi' | 'copilot' | 'cursor' + | 'devin' | 'custom'; /** Structured descriptor for how a NON-hiveAware provider gets hive lifecycle @@ -567,6 +568,29 @@ export const AGENT_PROVIDER_PRESETS: AgentProviderPreset[] = [ }, docsUrl: 'https://cursor.com/docs/cli/install' }, + { + id: 'devin', + label: 'Devin CLI', + defaultCommand: 'devin', + commandGroups: [], + // Devin's interactive CLI accepts the hive briefing after `--` as its + // initial prompt. It remains open for subsequent turns and inbox nudges. + autoModeFlag: '--permission-mode dangerous', + autoFlag: '--permission-mode dangerous', + autoStanceTokens: ['--permission-mode', '--sandbox'], + supportsModel: true, + modelFlag: '--model', + hiveAware: false, + canReceiveInbox: true, + initialPromptFlag: '--', + recommendedOrchestratorModel: 'swe-2', + resumeFlag: '--resume', + nativeInstallCommand: { + posix: 'curl -fsSL https://cli.devin.ai/install.sh | bash', + win32: 'powershell -c irm https://static.devin.ai/cli/setup.ps1 ^| iex' + }, + docsUrl: 'https://docs.devin.ai/cli' + }, { id: 'custom', label: 'Custom', @@ -594,6 +618,7 @@ export function isAgentProvider(value: unknown): value is AgentProvider { value === 'pi' || value === 'copilot' || value === 'cursor' || + value === 'devin' || value === 'custom' ); } @@ -633,8 +658,11 @@ function commandBinary(command: string | undefined): string { /** Infer the provider from a command (or honor an explicit override). */ export function inferAgentProvider(command: string | undefined, explicit?: unknown): AgentProvider { const normalized = normalizeAgentProvider(explicit); - if (normalized) return normalized; const bin = commandBinary(command); + // Older installs stored Devin commands as explicit `custom` agents because + // Devin had no preset. Recognise those on restore so existing agents inherit + // the model, prompt-file and permission handling without being recreated. + if (normalized && !(normalized === 'custom' && bin === 'devin')) return normalized; if (bin === 'codex') return 'codex'; if (bin === 'grok') return 'grok'; if (bin === 'kimi') return 'kimi'; @@ -645,6 +673,7 @@ export function inferAgentProvider(command: string | undefined, explicit?: unkno if (bin === 'crush') return 'crush'; if (bin === 'pi') return 'pi'; if (bin === 'copilot') return 'copilot'; + if (bin === 'devin') return 'devin'; // Cursor ships as `cursor-agent`; `agent` is a shorter alias (generic name — check last). if (bin === 'cursor-agent') return 'cursor'; if (bin === 'agent') return 'cursor'; @@ -699,7 +728,7 @@ export function hasAutoModeStance(args: string[], provider: AgentProvider): bool const flag = preset.autoModeFlag ?? ''; const lead = flag.trim().split(/\s+/)[0]; const stance = new Set([...(lead ? [lead] : []), ...(preset.autoStanceTokens ?? [])]); - return args.some((a) => stance.has(a)); + return args.some((a) => stance.has(a.split('=', 1)[0])); } /** Returns any env vars the provider needs for non-interactive / first-run suppression. */ diff --git a/src/shared/modelCatalog.json b/src/shared/modelCatalog.json index ec5e088f3..f2e1f6619 100644 --- a/src/shared/modelCatalog.json +++ b/src/shared/modelCatalog.json @@ -108,6 +108,22 @@ { "id": "claude-opus-4-8-high", "label": "Opus 4.8 1M", "minAppVersion": null, "maxAppVersion": null }, { "id": "claude-sonnet-5-thinking-high", "label": "Sonnet 5 1M Thinking", "minAppVersion": null, "maxAppVersion": null } ], + "devin": [ + { "label": "CLI default", "minAppVersion": null, "maxAppVersion": null }, + { "id": "adaptive", "label": "Adaptive", "minAppVersion": null, "maxAppVersion": null }, + { "id": "swe-2", "label": "SWE-2", "minAppVersion": null, "maxAppVersion": null }, + { "id": "swe-2-high", "label": "SWE-2 High", "minAppVersion": null, "maxAppVersion": null }, + { "id": "swe-2-medium", "label": "SWE-2 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "swe-2-max", "label": "SWE-2 Max", "minAppVersion": null, "maxAppVersion": null }, + { "id": "claude-opus-5-5-medium", "label": "Opus 5.5 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "claude-fable-5-1-medium", "label": "Fable 5.1 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "gpt-6-sol-medium", "label": "GPT-6 Sol Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "gpt-6-astra-medium", "label": "GPT-6 Astra Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "fusion-claude-opus-5-5-high-sidekick-swe-2-medium", "label": "Fusion · Opus 5.5 High + SWE-2 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "fusion-claude-fable-5-1-high-sidekick-swe-2-medium", "label": "Fusion · Fable 5.1 High + SWE-2 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "fusion-gpt-6-sol-high-sidekick-swe-2-medium", "label": "Fusion · GPT-6 Sol High + SWE-2 Medium", "minAppVersion": null, "maxAppVersion": null }, + { "id": "fusion-gpt-6-astra-high-sidekick-swe-2-medium", "label": "Fusion · GPT-6 Astra High + SWE-2 Medium", "minAppVersion": null, "maxAppVersion": null } + ], "custom": [] } } diff --git a/src/shared/providerAutomation.ts b/src/shared/providerAutomation.ts index 2ca684893..aedae6c74 100644 --- a/src/shared/providerAutomation.ts +++ b/src/shared/providerAutomation.ts @@ -144,6 +144,10 @@ const CONTEXT_COMMANDS: Record = { // auto-compact. Revisit when a shipped command table is transcribed. cursor: NO_CONTEXT_COMMANDS, + // Devin's Essential Commands reference lists /compact (no documented focus + // argument) and /clear for a fresh conversation. + devin: { compact: '/compact', clear: '/clear', compactTakesFocus: false }, + // An arbitrary user binary. We cannot know its command surface, and guessing // means typing slashes into someone's unknown REPL. custom: NO_CONTEXT_COMMANDS diff --git a/test/cli-install-ladder.test.cjs b/test/cli-install-ladder.test.cjs index 2672afba1..6b770eb1a 100644 --- a/test/cli-install-ladder.test.cjs +++ b/test/cli-install-ladder.test.cjs @@ -47,6 +47,15 @@ test('cursor prefers its native curl installer (no npm package)', () => { assert.match(withoutNpm.command, /cursor\.com\/install/); }); +test('Devin uses its official native installer on both platforms', () => { + const mac = installInfoForProvider('devin', 'darwin'); + const windows = installInfoForProvider('devin', 'win32'); + assert.equal(mac.command, undefined); + assert.match(mac.nativeCommand, /cli\.devin\.ai\/install\.sh/); + assert.match(windows.nativeCommand, /static\.devin\.ai\/cli\/setup\.ps1/); + assert.equal(chooseInstallRung(mac, true).kind, 'native'); +}); + test('with npm absent and no native installer, NOTHING is run', () => { const info = installInfoForProvider('codex'); assert.equal(info.nativeCommand, undefined, 'fixture assumes codex has no native installer'); diff --git a/test/devin-prompt.test.cjs b/test/devin-prompt.test.cjs new file mode 100644 index 000000000..d39bac388 --- /dev/null +++ b/test/devin-prompt.test.cjs @@ -0,0 +1,52 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); + +const { prepareDevinPromptFile } = loadTs('src/main/devinPrompt.ts'); + +test('Devin keeps an inline hive prompt when no personal prompt file is set', () => { + const args = ['--model', 'swe-2', '--', 'Hive briefing']; + assert.strictEqual(prepareDevinPromptFile(args, '/tmp', '/tmp/agent'), args); +}); + +test('Devin combines a personal prompt file with the hive briefing', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'md-devin-')); + try { + const agentDir = path.join(root, 'agent'); + fs.mkdirSync(agentDir); + const personalPath = path.join(root, 'DEVIN.md'); + fs.writeFileSync(personalPath, 'Personal identity\n'); + const args = ['--model', 'swe-2', '--permission-mode', 'dangerous', + '--prompt-file', 'DEVIN.md', '--', 'Hive briefing']; + + const result = prepareDevinPromptFile(args, root, agentDir); + assert.deepEqual(result.slice(0, 5), args.slice(0, 5)); + assert.equal(result.includes('--'), false); + assert.equal(result[5], path.join(agentDir, '.devin', 'initial-prompt.md')); + assert.equal(fs.readFileSync(result[5], 'utf8'), 'Personal identity\n\nHive briefing\n'); + assert.equal(fs.readFileSync(personalPath, 'utf8'), 'Personal identity\n'); + assert.equal(fs.statSync(result[5]).mode & 0o777, 0o600); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('Devin accepts the equals form of --prompt-file', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'md-devin-')); + try { + const agentDir = path.join(root, 'agent'); + fs.mkdirSync(agentDir); + fs.writeFileSync(path.join(root, 'instructions.md'), 'Personal'); + const result = prepareDevinPromptFile( + ['--prompt-file=instructions.md', '--', 'Hive'], root, agentDir + ); + assert.deepEqual(result, [`--prompt-file=${path.join(agentDir, '.devin', 'initial-prompt.md')}`]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/model-catalog-remote.test.cjs b/test/model-catalog-remote.test.cjs index d83e1812d..f90484ae3 100644 --- a/test/model-catalog-remote.test.cjs +++ b/test/model-catalog-remote.test.cjs @@ -65,6 +65,11 @@ test('the remote file and the baked file agree on every model', () => { assert.deepEqual(remote.providers, baked.providers); }); +test('Devin has the same SWE-2 choices before and after a remote refresh', () => { + const remote = require('../docs/model-catalog.json'); + assert.deepEqual(remote.providers.devin, baked.providers.devin); +}); + test('a version this build does not know is rejected whole', () => { assert.equal(parseModelCatalog({ version: 2, providers: { claude: [{ label: 'X' }] } }), null); assert.equal(parseModelCatalog({ providers: { claude: [{ label: 'X' }] } }), null); diff --git a/test/model-catalog.test.cjs b/test/model-catalog.test.cjs index 82ccf8dfb..82f2768f7 100644 --- a/test/model-catalog.test.cjs +++ b/test/model-catalog.test.cjs @@ -148,7 +148,7 @@ test('the catalog is the schema config.ts expects', () => { assert.equal(catalog.version, 1); assert.deepEqual( Object.keys(catalog.providers).sort(), - ['antigravity', 'claude', 'copilot', 'codex', 'crush', 'cursor', 'custom', + ['antigravity', 'claude', 'copilot', 'codex', 'crush', 'cursor', 'custom', 'devin', 'gemini', 'grok', 'kimi', 'opencode', 'pi', 'qwen'].sort() ); }); diff --git a/test/provider-automation.test.cjs b/test/provider-automation.test.cjs index 09ce203e5..c35807ee0 100644 --- a/test/provider-automation.test.cjs +++ b/test/provider-automation.test.cjs @@ -17,7 +17,7 @@ const { // — the queue's one-pending-compact invariant depends entirely on this predicate — test('isCompactionCommand matches every provider that has a compact verb', () => { - for (const p of ['claude', 'codex', 'grok', 'kimi', 'qwen', 'opencode', 'pi', 'copilot', 'cursor']) { + for (const p of ['claude', 'codex', 'grok', 'kimi', 'qwen', 'opencode', 'pi', 'copilot', 'cursor', 'devin']) { const cmd = compactionCommandForProvider(p, ''); if (!cmd) continue; // provider has no typeable compaction — nothing to dedupe assert.equal(isCompactionCommand(cmd), true, `${p}: ${cmd}`); @@ -56,6 +56,7 @@ test('each provider receives only its supported compaction syntax', () => { assert.equal(compactionCommandForProvider('qwen', ''), '/compress'); assert.equal(compactionCommandForProvider('opencode', ''), '/compact'); assert.equal(compactionCommandForProvider('pi', ''), '/compact'); + assert.equal(compactionCommandForProvider('devin', ''), '/compact'); // No command we can trust → no keystrokes at all. for (const p of ['antigravity', 'crush', 'copilot', 'cursor', 'custom']) { @@ -74,6 +75,7 @@ test('the focus rides along only where the TUI parses it', () => { // codex/opencode ignore trailing text, so it must be dropped, not typed. assert.equal(compactionCommandForProvider('codex', focus), '/compact'); assert.equal(compactionCommandForProvider('opencode', focus), '/compact'); + assert.equal(compactionCommandForProvider('devin', focus), '/compact'); // Omitting the message keeps the trigger default, not a bare command. assert.equal( @@ -89,6 +91,7 @@ test('clearing uses each CLI own verb, not a hardcoded /clear', () => { assert.equal(clearCommandForProvider('codex'), '/clear'); assert.equal(clearCommandForProvider('kimi'), '/clear'); assert.equal(clearCommandForProvider('qwen'), '/clear'); + assert.equal(clearCommandForProvider('devin'), '/clear'); // agy's /clear resets the conversation; Ctrl+L is the screen-only one. assert.equal(clearCommandForProvider('antigravity'), '/clear'); // These three start a fresh session instead — '/clear' is not a command there. diff --git a/test/provider-config.test.cjs b/test/provider-config.test.cjs index eab71c764..d621f655e 100644 --- a/test/provider-config.test.cjs +++ b/test/provider-config.test.cjs @@ -90,6 +90,42 @@ test('every non-hive-aware inbox provider declares exactly one bootstrap deliver } }); +test('Devin is selectable with an interactive prompt, model, and permission flags', () => { + assert.equal(isAgentProvider('devin'), true); + assert.equal(inferAgentProvider('/opt/homebrew/bin/devin --model swe-2'), 'devin'); + assert.equal(inferAgentProvider('devin --permission-mode dangerous --prompt-file /tmp/DEVIN.md', 'custom'), 'devin'); + assert.equal(inferAgentProvider('my-wrapper --model swe-2', 'custom'), 'custom'); + const preset = providerPreset('devin'); + assert.equal(preset.defaultCommand, 'devin'); + assert.equal(preset.initialPromptFlag, '--'); + assert.equal(preset.autoFlag, '--permission-mode dangerous'); + assert.deepEqual(preset.autoStanceTokens, ['--permission-mode', '--sandbox']); + assert.equal(preset.modelFlag, '--model'); + assert.equal(preset.canReceiveInbox, true); + assert.equal(preset.resumeFlag, '--resume'); + assert.equal( + buildSpawnCommand(autoConfig, 'swe-2', 'devin'), + 'devin --model swe-2 --permission-mode dangerous' + ); + assert.deepEqual(modelsForProvider('devin').map((model) => model.id), + [ + undefined, + 'adaptive', + 'swe-2', + 'swe-2-high', + 'swe-2-medium', + 'swe-2-max', + 'claude-opus-5-5-medium', + 'claude-fable-5-1-medium', + 'gpt-6-sol-medium', + 'gpt-6-astra-medium', + 'fusion-claude-opus-5-5-high-sidekick-swe-2-medium', + 'fusion-claude-fable-5-1-high-sidekick-swe-2-medium', + 'fusion-gpt-6-sol-high-sidekick-swe-2-medium', + 'fusion-gpt-6-astra-high-sidekick-swe-2-medium' + ]); +}); + test('provider commands use matching models and equivalent bypass modes', () => { assert.equal( buildSpawnCommand(autoConfig, 'claude-sonnet-5', 'claude'), @@ -177,10 +213,10 @@ test('God only sees providers that can drain hive inbox messages', () => { // Cursor is interactive (no -p) so it IS god-eligible. assert.deepEqual( modelProvidersForAgent(true).map((preset) => preset.id), - ['claude', 'codex', 'grok', 'gemini', 'antigravity', 'qwen', 'opencode', 'crush', 'pi', 'cursor'] + ['claude', 'codex', 'grok', 'gemini', 'antigravity', 'qwen', 'opencode', 'crush', 'pi', 'cursor', 'devin'] ); assert.deepEqual( modelProvidersForAgent(false).map((preset) => preset.id), - ['claude', 'codex', 'grok', 'kimi', 'gemini', 'antigravity', 'qwen', 'opencode', 'crush', 'pi', 'copilot', 'cursor'] + ['claude', 'codex', 'grok', 'kimi', 'gemini', 'antigravity', 'qwen', 'opencode', 'crush', 'pi', 'copilot', 'cursor', 'devin'] ); }); diff --git a/test/worker-permission-parity.test.cjs b/test/worker-permission-parity.test.cjs index 7a7a2829f..6eb18d229 100644 --- a/test/worker-permission-parity.test.cjs +++ b/test/worker-permission-parity.test.cjs @@ -66,3 +66,13 @@ test('codex\'s multi-token auto flag is applied the same way as Claude\'s two-to ['--model', 'gpt-5.6-sol', '-a', 'never', '-s', 'workspace-write'] ); }); + +test('Devin auto mode honours an explicit permission choice', () => { + assert.deepEqual(argsWithAutoModeFlag(['--model', 'swe-2'], true, 'devin'), + ['--model', 'swe-2', '--permission-mode', 'dangerous']); + assert.deepEqual(argsWithAutoModeFlag(['--permission-mode', 'smart'], true, 'devin'), + ['--permission-mode', 'smart']); + assert.deepEqual(argsWithAutoModeFlag(['--permission-mode=smart'], true, 'devin'), + ['--permission-mode=smart']); + assert.deepEqual(argsWithAutoModeFlag(['--sandbox'], true, 'devin'), ['--sandbox']); +});