diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 9a57a2eec3a..fba1c588fdb 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -11,6 +11,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@parcel/watcher', 'electron-updater', 'i18next', + 'jsonc-parser', 'node-pty', 'posthog-node', // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index e606895e682..2e218b9d0c0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -21,6 +21,9 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/devin/hook-settings.ts", + "../src/main/devin/hook-service.ts", + "../src/main/devin/hook-config-json.ts", "../src/main/hermes/hook-service.ts", "../src/main/openclaude/hook-service.ts", "../src/main/runtime/runtime-metadata.ts", diff --git a/package.json b/package.json index 702ec0789a0..db514a86c6c 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "agent-browser": "~0.27.0", "electron-updater": "^6.8.3", "i18next": "^26.3.1", + "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad422a9c77..32f053c79ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: i18next: specifier: ^26.3.1 version: 26.3.1(typescript@5.9.3) + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index cbe70dbf45d..00338d5669a 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -10,6 +10,7 @@ import { cursorHookService } from '../cursor/hook-service' import { droidHookService } from '../droid/hook-service' import { commandCodeHookService } from '../command-code/hook-service' import { geminiHookService } from '../gemini/hook-service' +import { devinHookService } from '../devin/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' @@ -30,7 +31,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['command-code', () => commandCodeHookService.install()], ['grok', () => grokHookService.install()], ['copilot', () => copilotHookService.install()], - ['hermes', () => hermesHookService.install()] + ['hermes', () => hermesHookService.install()], + ['devin', () => devinHookService.install()] ] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ @@ -45,7 +47,8 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['command-code', () => commandCodeHookService.remove()], ['grok', () => grokHookService.remove()], ['copilot', () => copilotHookService.remove()], - ['hermes', () => hermesHookService.remove()] + ['hermes', () => hermesHookService.remove()], + ['devin', () => devinHookService.remove()] ] const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ @@ -57,10 +60,11 @@ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['amp', () => ampHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], ['droid', () => droidHookService.getStatus()], - ['command-code', () => commandCodeHookService.getStatus()], ['grok', () => grokHookService.getStatus()], + ['command-code', () => commandCodeHookService.getStatus()], ['copilot', () => copilotHookService.getStatus()], - ['hermes', () => hermesHookService.getStatus()] + ['hermes', () => hermesHookService.getStatus()], + ['devin', () => devinHookService.getStatus()] ] export function isAgentStatusHooksEnabled( diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index b9c917e9cd8..8af3627a845 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -18,6 +18,7 @@ import { ClaudeHookService } from '../claude/hook-service' import { GrokHookService } from '../grok/hook-service' import { CopilotHookService } from '../copilot/hook-service' import { HermesHookService } from '../hermes/hook-service' +import { DevinHookService } from '../devin/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' type FakeFs = { @@ -161,6 +162,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/copilot-hook.sh', install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/devin-hook.sh', + install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, '/home/dev') } ] @@ -227,13 +232,21 @@ describe('remote hook service installers', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') }) - it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => { + it('installs remote Gemini, Antigravity, Cursor, Command Code, Grok, and Devin configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const antigravity = createFakeSftp() const amp = createFakeSftp() const cursor = createFakeSftp() const commandCode = createFakeSftp() const grok = createFakeSftp() + const devin = createFakeSftp({ + '/home/dev/.config/devin/config.json': `{ + // Existing Devin config comment + "hooks": {}, + "permissions": { "mode": "normal" } +} +` + }) await new GeminiHookService().installRemote(gemini.sftp, '/home/dev') await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev') @@ -241,6 +254,7 @@ describe('remote hook service installers', () => { await new CursorHookService().installRemote(cursor.sftp, '/home/dev') await new CommandCodeHookService().installRemote(commandCode.sftp, '/home/dev') await new GrokHookService().installRemote(grok.sftp, '/home/dev') + await new DevinHookService().installRemote(devin.sftp, '/home/dev') const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as { hooks: Record @@ -333,6 +347,55 @@ describe('remote hook service installers', () => { expect(command).toMatch(/^if \[ -x /) } expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*') + + const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as { + permissions: { mode: string } + hooks: Record + } + expect(devinConfig.permissions.mode).toBe('normal') + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'PostCompaction', + 'SessionEnd' + ]) { + const definition = devinConfig.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { + const definition = devinConfig.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(definition?.matcher).toBeUndefined() + expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin') + }) + + it('does not overwrite malformed remote Devin JSONC', async () => { + const original = '{"hooks": }' + const { sftp, fs } = createFakeSftp({ + '/home/dev/.config/devin/config.json': original + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const status = await new DevinHookService().installRemote(sftp, '/home/dev') + + expect(status).toMatchObject({ + agent: 'devin', + state: 'error', + configPath: '/home/dev/.config/devin/config.json', + managedHooksPresent: false, + detail: 'Could not parse remote Devin config.json' + }) + expect(fs.files.get('/home/dev/.config/devin/config.json')).toBe(original) + expect(fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toBeUndefined() + } finally { + warn.mockRestore() + } }) it('removes stale remote Antigravity PreToolUse hooks while installing SSH hooks', async () => { diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a199b1596b4..95048dd3791 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -7,6 +7,7 @@ import { geminiHookService } from '../gemini/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { cursorHookService } from '../cursor/hook-service' import { commandCodeHookService } from '../command-code/hook-service' +import { devinHookService } from '../devin/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' @@ -26,7 +27,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], - ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)] + ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], + ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)] ] export async function installRemoteManagedAgentHooks( diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts new file mode 100644 index 00000000000..e97bb196aa1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -0,0 +1,54 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseGrokSessionFile } from './session-scanner-grok-parser' +import { + parseDroidSessionFile, + parseMessageGraphSessionFile, + parseRovoSessionFile +} from './session-scanner-graph-parsers' +import { + parseClaudeSessionFile, + parseCodexSessionFile, + parseGeminiSessionFile +} from './session-scanner-primary-parsers' +import { + parseCopilotSessionFile, + parseCursorSessionFile, + parseHermesSessionFile, + parseOpenCodeSessionFile +} from './session-scanner-secondary-parsers' +import type { SessionFileCandidate } from './session-scanner-types' + +export async function parseAgentSessionFile( + candidate: SessionFileCandidate, + platform: NodeJS.Platform +): Promise { + switch (candidate.agent) { + case 'claude': + return parseClaudeSessionFile(candidate.file, platform) + case 'codex': + return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) + case 'gemini': + return parseGeminiSessionFile(candidate.file, platform) + case 'copilot': + return parseCopilotSessionFile(candidate.file, platform) + case 'cursor': + return parseCursorSessionFile(candidate.file, platform) + case 'opencode': + return parseOpenCodeSessionFile(candidate.file, platform) + case 'grok': + return parseGrokSessionFile(candidate.file, platform) + case 'hermes': + return parseHermesSessionFile(candidate.file, platform) + case 'rovo': + return parseRovoSessionFile(candidate.file, platform) + case 'openclaw': + return parseMessageGraphSessionFile('openclaw', candidate.file, platform) + case 'pi': + return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'droid': + return parseDroidSessionFile(candidate.file, platform) + case 'devin': + return parseDevinSessionFile(candidate.file, platform) + } +} diff --git a/src/main/ai-vault/session-scanner-devin-parser.test.ts b/src/main/ai-vault/session-scanner-devin-parser.test.ts new file mode 100644 index 00000000000..889383e6342 --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-parser.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseDevinSessionFile } from './session-scanner-devin-parser' + +let tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('parseDevinSessionFile', () => { + it('parses minimal ATIF transcript fixture', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-')) + tempDirs.push(dir) + const path = join(dir, 'abc.json') + const mtimeMs = Date.now() + await writeFile( + path, + JSON.stringify({ + session_id: 'abc', + agent: { model_name: 'swe-1-6-fast' }, + steps: [ + { + metadata: { + created_at: '2026-01-01T00:00:00Z', + is_user_input: true, + metrics: { input_tokens: 1, output_tokens: 2 } + }, + text: 'Hello Devin' + } + ] + }) + ) + + const session = await parseDevinSessionFile({ + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }) + + expect(session).not.toBeNull() + expect(session?.sessionId).toBe('abc') + expect(session?.model).toBe('swe-1-6-fast') + expect(session?.totalTokens).toBe(3) + expect(session?.messageCount).toBe(1) + expect(session?.title).toBe('Hello Devin') + }) + + it('parses current ATIF token and model fields', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-')) + tempDirs.push(dir) + const path = join(dir, 'current.json') + const mtimeMs = Date.now() + await writeFile( + path, + JSON.stringify({ + session_id: 'current', + agent: {}, + steps: [ + { + role: 'assistant', + metadata: { + created_at: '2026-05-26T00:00:00Z', + generation_model: 'swe-1-6', + total_input_tokens: 10, + output_tokens: 4, + cache_read_tokens: 3, + cache_creation_tokens: 2 + }, + message: { + content: 'Done' + } + } + ] + }) + ) + + const session = await parseDevinSessionFile({ + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }) + + expect(session?.model).toBe('swe-1-6') + expect(session?.totalTokens).toBe(19) + expect(session?.messageCount).toBe(1) + expect(session?.previewMessages[0]).toMatchObject({ + role: 'assistant', + text: 'Done' + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts new file mode 100644 index 00000000000..ad074fb9a35 --- /dev/null +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -0,0 +1,115 @@ +import { readFile } from 'fs/promises' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { FileWithMtime } from './session-scanner-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + normalizeTitleText, + numberValue +} from './session-scanner-values' + +export async function parseDevinSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { + return null + } + const sessionId = + extractString(record.session_id) ?? + extractString(record.sessionId) ?? + sessionIdFromFileName(file.path) + const accumulator = createAccumulator({ agent: 'devin', file, sessionId }) + const agentRecord = asRecord(record.agent) + accumulator.model = + extractString(agentRecord?.model_name) ?? + extractString(agentRecord?.model) ?? + extractString(record.generation_model) + accumulator.cwd = extractString(record.working_directory) + const steps = arrayValue(record.steps) + for (const step of steps) { + const stepRecord = asRecord(step) + if (!stepRecord) { + continue + } + const metadata = asRecord(stepRecord.metadata) + updateTimeline(accumulator, extractString(metadata?.created_at)) + const metrics = asRecord(metadata?.metrics) + accumulator.model ??= + extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) + accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) + const isUser = metadata?.is_user_input === true + if (isUser) { + accumulator.messageCount++ + const text = + extractDevinStepText(stepRecord) ?? + extractContentText(stepRecord.content) ?? + extractString(stepRecord.text) + const titleCandidate = normalizeTitleText(text ?? '') + if (titleCandidate) { + accumulator.title ??= titleCandidate + } + addPreviewContent(accumulator, 'user', text ?? stepRecord.content) + } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { + accumulator.messageCount++ + addPreviewContent( + accumulator, + 'assistant', + extractDevinStepText(stepRecord) ?? stepRecord.content + ) + } + } + return finalizeSession(accumulator, platform) +} + +function extractDevinStepText(step: Record): string | null { + const message = asRecord(step.message) + if (message) { + return extractContentText(message.content) ?? extractString(message.content) + } + return extractString(step.text) +} + +function devinStepTokenTotal( + metadata: Record | null, + metrics: Record | null +): number { + return ( + numberFromDevinMetadata(metadata, metrics, ['total_input_tokens', 'input_tokens']) + + numberFromDevinMetadata(metadata, metrics, ['output_tokens']) + + numberFromDevinMetadata(metadata, metrics, ['cache_read_tokens', 'cache_read_input_tokens']) + + numberFromDevinMetadata(metadata, metrics, [ + 'cache_creation_tokens', + 'cache_creation_input_tokens' + ]) + ) +} + +function numberFromDevinMetadata( + metadata: Record | null, + metrics: Record | null, + keys: readonly string[] +): number { + for (const source of [metadata, metrics]) { + if (!source) { + continue + } + for (const key of keys) { + const value = numberValue(source[key]) + if (value > 0) { + return value + } + } + } + return 0 +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index da494568adb..6f6eca3359b 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,7 @@ export type AiVaultScanOptions = { cursorProjectsDir?: string opencodeStorageDir?: string grokSessionsDir?: string + devinTranscriptsDir?: string hermesSessionsDir?: string rovoSessionsDir?: string openclawStateDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 1efaacdacdb..a01430a2453 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -21,6 +21,7 @@ function isolatedScanRoots(root: string) { cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), rovoSessionsDir: join(root, 'rovo-sessions'), openclawStateDir: join(root, 'openclaw-state'), @@ -526,6 +527,26 @@ describe('scanAiVaultSessions', () => { ]) ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) + await writeFile( + join(roots.devinTranscriptsDir, 'devin-session.json'), + JSON.stringify({ + session_id: 'devin-session', + working_directory: '/tmp/devin', + agent: { model_name: 'swe-1-6-fast' }, + steps: [ + { + metadata: { + created_at: '2026-05-01T10:10:00.000Z', + is_user_input: true, + metrics: { input_tokens: 1, output_tokens: 2 } + }, + text: 'Devin vault title' + } + ] + }) + ) + await mkdir(roots.droidSessionsDir, { recursive: true }) await writeFile( join(roots.droidSessionsDir, 'droid-session.jsonl'), @@ -592,6 +613,9 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + expect(commandByAgent.get('devin')).toBe( + "cd '/tmp/devin' && devin --resume 'devin-session'" + ) expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") }) }) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index b3f612cea70..4623ff29a6b 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,25 +6,9 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { sessionSortTime } from './session-scanner-accumulator' +import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' -import { parseGrokSessionFile } from './session-scanner-grok-parser' -import { - parseDroidSessionFile, - parseMessageGraphSessionFile, - parseRovoSessionFile -} from './session-scanner-graph-parsers' -import { - parseClaudeSessionFile, - parseCodexSessionFile, - parseGeminiSessionFile -} from './session-scanner-primary-parsers' -import { - parseCopilotSessionFile, - parseCursorSessionFile, - parseHermesSessionFile, - parseOpenCodeSessionFile -} from './session-scanner-secondary-parsers' import type { AiVaultScanOptions, SessionFileCandidate, @@ -65,6 +49,11 @@ const PI_SESSIONS_DIR = normalizePiSessionsDir( process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') ) const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') +// Why: Devin ATIF transcripts are stored under /transcripts. +const DEVIN_TRANSCRIPTS_DIR = join( + process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), + 'transcripts' +) export async function scanAiVaultSessions( options: AiVaultScanOptions = {} @@ -132,6 +121,13 @@ export async function scanAiVaultSessions( extensions: ['.json'], filePredicate: (path) => basename(path) === 'summary.json' }), + discoverFiles({ + rootDir: options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, + limit: limitPerAgent, + agent: 'devin', + issues, + extensions: ['.json'] + }), discoverFiles({ rootDir: options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, limit: limitPerAgent, @@ -268,38 +264,6 @@ async function parseSessionCandidate( } } -async function parseAgentSessionFile( - candidate: SessionFileCandidate, - platform: NodeJS.Platform -): Promise { - switch (candidate.agent) { - case 'claude': - return parseClaudeSessionFile(candidate.file, platform) - case 'codex': - return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) - case 'gemini': - return parseGeminiSessionFile(candidate.file, platform) - case 'copilot': - return parseCopilotSessionFile(candidate.file, platform) - case 'cursor': - return parseCursorSessionFile(candidate.file, platform) - case 'opencode': - return parseOpenCodeSessionFile(candidate.file, platform) - case 'grok': - return parseGrokSessionFile(candidate.file, platform) - case 'hermes': - return parseHermesSessionFile(candidate.file, platform) - case 'rovo': - return parseRovoSessionFile(candidate.file, platform) - case 'openclaw': - return parseMessageGraphSessionFile('openclaw', candidate.file, platform) - case 'pi': - return parseMessageGraphSessionFile('pi', candidate.file, platform) - case 'droid': - return parseDroidSessionFile(candidate.file, platform) - } -} - function canStopParsingSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 4a1a7b58cbb..e0ad45b63e2 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -169,6 +169,9 @@ describe('ClaudeHookService.install', () => { ) ).toBe(false) expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME) + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') + ).toContain('DEVIN_PROJECT_DIR') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -206,7 +209,9 @@ describe('ClaudeHookService.installRemote', () => { expect(cmd).toMatch(/^if \[ -x /) } // Managed script body - expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh') + const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') + expect(script).toContain('#!/bin/sh') + expect(script).toContain('DEVIN_PROJECT_DIR') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) }) @@ -287,6 +292,9 @@ describe('OpenClaudeHookService-compatible install', () => { expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('/hook/claude') + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + ).not.toContain('DEVIN_PROJECT_DIR') expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { vi.unstubAllEnvs() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 7eb208a9951..c6a3229478d 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -38,11 +38,21 @@ const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { settings: CLAUDE_HOOK_SETTINGS } -function getManagedScript(target: 'local' | 'posix' = 'local'): string { +function getManagedScript( + target: 'local' | 'posix' = 'local', + options: { skipWhenDevinImportsClaude?: boolean } = {} +): string { if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default. Skip Orca's managed + // Claude hook there so status posts stay attributed to Devin. + 'if not "%DEVIN_PROJECT_DIR%"=="" exit /b 0' + ] + : []), // Why: the endpoint file holds the *live* port/token for this Orca // install. A PTY that survived an Orca restart has stale PORT/TOKEN // baked into its env from the old instance — loading `endpoint.cmd` @@ -61,6 +71,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default. Skip Orca's managed + // Claude hook there so status posts stay attributed to Devin. + 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', + ' exit 0', + 'fi' + ] + : []), // Why: the endpoint file holds the *live* port/token for this Orca // install. PTYs that survive an Orca restart have stale PORT/TOKEN // baked into their env from the old instance — sourcing the file here @@ -181,7 +200,10 @@ export class ClaudeHookService { command, getManagedScriptFileName(this.options.settings) ) - writeManagedScript(scriptPath, getManagedScript()) + writeManagedScript( + scriptPath, + getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) writeHooksJson(configPath, nextConfig) return this.getStatus() } @@ -229,7 +251,11 @@ export class ClaudeHookService { // of broken settings.json. // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is // running on Windows; never derive remote script syntax from local OS. - await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeManagedScriptRemote( + sftp, + remoteScriptPath, + getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index b62590542a4..d2fcc3512f1 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -33,6 +33,7 @@ describe('packaged CLI assets', () => { join('node_modules', 'tweetnacl'), join('node_modules', 'zod'), join('node_modules', 'yaml'), + join('node_modules', 'jsonc-parser'), join('node_modules', 'node-pty'), join('node_modules', 'sherpa-onnx-darwin-${arch}'), join('node_modules', 'sherpa-onnx-linux-${arch}'), diff --git a/src/main/devin/hook-config-json.test.ts b/src/main/devin/hook-config-json.test.ts new file mode 100644 index 00000000000..b8baa34f88a --- /dev/null +++ b/src/main/devin/hook-config-json.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + parseDevinHooksConfigText, + readConfigFromOrcaOverlapDetail, + readDevinHooksConfig +} from './hook-config-json' + +describe('readDevinHooksConfig', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-devin-jsonc-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('parses JSONC comments in Devin config', () => { + const path = join(dir, 'config.json') + writeFileSync( + path, + `{ + // Devin user hooks + "hooks": {}, + "permissions": { "mode": "normal" } +} +` + ) + + const config = readDevinHooksConfig(path) + + expect(config).toEqual({ + hooks: {}, + permissions: { mode: 'normal' } + }) + }) + + it('rejects recovered partial parses from malformed JSONC', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(parseDevinHooksConfigText('{"hooks": }', 'Devin config.json')).toBeNull() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not parse Devin config.json') + ) + } finally { + warn.mockRestore() + } + }) +}) + +describe('readConfigFromOrcaOverlapDetail', () => { + it('warns when legacy read_config_from imports Claude', () => { + const detail = readConfigFromOrcaOverlapDetail({ + hooks: {}, + read_config_from: ['claude', 'custom'] + }) + + expect(detail).toContain('read_config_from') + expect(detail).toContain('claude') + }) + + it('warns when object-shaped read_config_from leaves Claude enabled', () => { + const detail = readConfigFromOrcaOverlapDetail({ + hooks: {}, + read_config_from: { claude: true } + }) + + expect(detail).toContain('read_config_from.claude') + }) + + it('warns when read_config_from is omitted because imports default to enabled', () => { + const detail = readConfigFromOrcaOverlapDetail({ + hooks: {} + }) + + expect(detail).toContain('read_config_from.claude') + }) + + it('does not warn when read_config_from disables Claude', () => { + const detail = readConfigFromOrcaOverlapDetail({ + hooks: {}, + read_config_from: { claude: false } + }) + + expect(detail).toBeNull() + }) +}) diff --git a/src/main/devin/hook-config-json.ts b/src/main/devin/hook-config-json.ts new file mode 100644 index 00000000000..e682df8b926 --- /dev/null +++ b/src/main/devin/hook-config-json.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync } from 'fs' +import { parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils' + +/** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */ +export function readDevinHooksConfig(configPath: string): HooksConfig | null { + if (!existsSync(configPath)) { + return {} + } + + try { + const text = readFileSync(configPath, 'utf-8') + return parseDevinHooksConfigText(text, 'Devin config.json') + } catch { + return null + } +} + +export function parseDevinHooksConfigText( + text: string, + diagnosticName: string +): HooksConfig | null { + const errors: ParseError[] = [] + const parsed = parseJsonc(text, errors) + if (errors.length > 0) { + console.warn( + `Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}` + ) + return null + } + if (parsed === undefined) { + return null + } + return isPlainObject(parsed) ? (parsed as HooksConfig) : null +} + +/** Devin imports Claude hooks by default, so surface that overlap explicitly. */ +export function readConfigFromOrcaOverlapDetail( + config: HooksConfig & { read_config_from?: unknown } +): string | null { + if (!isClaudeConfigImportEnabled(config.read_config_from)) { + return null + } + + return 'Devin read_config_from.claude is enabled; imported Claude hooks may fire alongside Devin hooks.' +} + +function isClaudeConfigImportEnabled(raw: unknown): boolean { + if (raw === undefined || raw === null || raw === true) { + return true + } + if (raw === false) { + return false + } + if (Array.isArray(raw)) { + return raw.includes('claude') + } + if (!isPlainObject(raw)) { + return false + } + return raw.claude !== false +} + +export function mergeHookInstallDetail(base: string | null, extra: string | null): string | null { + if (!extra) { + return base + } + if (!base) { + return extra + } + return `${base} ${extra}` +} diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts new file mode 100644 index 00000000000..46e1b2b4d0f --- /dev/null +++ b/src/main/devin/hook-service.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { DevinHookService } from './hook-service' +import { getDevinConfigPath, getDevinManagedCommand } from './hook-settings' + +describe('DevinHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs managed hooks into user Devin config and posts to /hook/devin', () => { + const status = new DevinHookService().install() + + expect(status.state).toBe('installed') + expect(status.agent).toBe('devin') + expect(status.configPath).toBe(join(homeDir, '.config', 'devin', 'config.json')) + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse( + readFileSync(join(homeDir, '.config', 'devin', 'config.json'), 'utf8') + ) as { + hooks: Record + agent?: { model: string } + } + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'PostCompaction', + 'SessionEnd' + ]) { + expect(config.hooks[eventName][0].hooks[0].command).toContain('devin-hook') + } + for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { + expect(config.hooks[eventName][0].matcher).toBeUndefined() + } + const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh'), 'utf8') + expect(script).toContain('/hook/devin') + }) + + it('preserves unrelated keys in Devin config when installing hooks', () => { + const configPath = join(homeDir, '.config', 'devin', 'config.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify({ permissions: { mode: 'normal' }, hooks: {} }, null, 2)}\n` + ) + + new DevinHookService().install() + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + permissions: { mode: string } + hooks: Record + } + expect(config.permissions.mode).toBe('normal') + expect(config.hooks.UserPromptSubmit).toBeDefined() + }) + + it('installs when Devin config uses JSONC comments', () => { + const configPath = join(homeDir, '.config', 'devin', 'config.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `{ + // user hooks + "hooks": {} +} +` + ) + + const status = new DevinHookService().install() + + expect(status.state).toBe('installed') + expect(JSON.parse(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined() + }) + + it('surfaces read_config_from overlap in status detail', () => { + const configPath = join(homeDir, '.config', 'devin', 'config.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify({ hooks: {}, read_config_from: { claude: true } }, null, 2)}\n` + ) + + const status = new DevinHookService().getStatus() + + expect(status.detail).toContain('read_config_from') + expect(status.detail).toContain('claude') + }) + + it('uses a cmd.exe wrapper for managed hook command on Windows', () => { + const previous = process.platform + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const scriptPath = 'C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd' + expect(getDevinManagedCommand(scriptPath)).toBe( + 'cmd /d /s /c ""C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd""' + ) + } finally { + Object.defineProperty(process, 'platform', { value: previous }) + } + }) + + it('reports not_installed when Devin config has no managed hooks', () => { + const configPath = join(homeDir, '.config', 'devin', 'config.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, `${JSON.stringify({ hooks: {} }, null, 2)}\n`) + + const status = new DevinHookService().getStatus() + + expect(status.state).toBe('not_installed') + expect(status.managedHooksPresent).toBe(false) + }) + + it('remove clears managed hook commands from Devin config', () => { + const service = new DevinHookService() + const installed = service.install() + expect(installed.state).toBe('installed') + + const removed = service.remove() + + expect(removed.state).toBe('not_installed') + const configPath = join(homeDir, '.config', 'devin', 'config.json') + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks: Record + } + const commands = Object.values(config.hooks).flatMap((definitions) => + definitions.flatMap((definition) => definition.hooks.map((hook) => hook.command)) + ) + expect(commands.some((command) => command.includes('devin-hook'))).toBe(false) + }) + + it('returns partial status when some managed hooks are missing', () => { + const configPath = join(homeDir, '.config', 'devin', 'config.json') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh') + const command = getDevinManagedCommand(scriptPath) + mkdirSync(dirname(configPath), { recursive: true }) + mkdirSync(dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, '#!/bin/sh\n') + + // Only install the managed hook for UserPromptSubmit + writeFileSync( + configPath, + `${JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command }] }] } }, null, 2)}\n` + ) + + const status = new DevinHookService().getStatus() + + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Stop') + expect(status.detail).toContain('PreToolUse') + expect(status.detail).toContain('PostToolUse') + expect(status.detail).toContain('PermissionRequest') + expect(status.detail).toContain('SessionStart') + expect(status.detail).toContain('PostCompaction') + expect(status.detail).toContain('SessionEnd') + }) + + it('uses APPDATA on Windows for Devin config path', () => { + const previous = process.platform + const previousAppData = process.env.APPDATA + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming' + expect(getDevinConfigPath()).toBe( + join('C:\\Users\\test\\AppData\\Roaming', 'devin', 'config.json') + ) + + // Fallback when APPDATA is unset + delete process.env.APPDATA + expect(getDevinConfigPath()).toBe(join(homeDir, 'AppData', 'Roaming', 'devin', 'config.json')) + } finally { + Object.defineProperty(process, 'platform', { value: previous }) + if (previousAppData !== undefined) { + process.env.APPDATA = previousAppData + } else { + delete process.env.APPDATA + } + } + }) +}) diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts new file mode 100644 index 00000000000..1a2f6a3d05d --- /dev/null +++ b/src/main/devin/hook-service.ts @@ -0,0 +1,268 @@ +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { + readTextFileRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' +import { + applyDevinManagedHooks, + DEVIN_EVENTS, + getDevinConfigPath, + getDevinManagedCommand, + getDevinManagedScriptFileName, + getDevinManagedScriptPath, + getDevinPosixManagedScriptFileName, + getDevinRemoteConfigPath, + getDevinRemoteManagedCommand, + removeDevinManagedHooks +} from './hook-settings' +import { + mergeHookInstallDetail, + parseDevinHooksConfigText, + readConfigFromOrcaOverlapDetail, + readDevinHooksConfig +} from './hook-config-json' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: the endpoint file holds the *live* port/token for this Orca + // install. A PTY that survived an Orca restart has stale PORT/TOKEN + // baked into its env from the old instance — loading `endpoint.cmd` + // (`set KEY=VALUE` lines) via `call` refreshes them so the hook + // reaches the current server. Falls through to PTY env if the file + // is missing (first run / pre-endpoint-file / running outside Orca). + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + buildWindowsAgentHookPostCommand('devin'), + 'exit /b 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: the endpoint file holds the *live* port/token for this Orca + // install. PTYs that survive an Orca restart have stale PORT/TOKEN + // baked into their env from the old instance — sourcing the file here + // lets us reach the new server. Falls back to PTY env if the file is + // missing (first-run / pre-endpoint-file scripts / running outside Orca). + // Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked + // between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF + // bled in from a cross-platform userData copy) would otherwise print a + // parse error that agent transcripts could surface. Stale coords → dead + // port → silent-fail is the documented fail-open path anyway — the env-var + // guards below handle the empty PORT/TOKEN case — so swallowing the noise + // here is strictly better than leaking shell errors into the hook output. + // `|| :` defends against an eventual `set -e` in an outer script context + // (not present today) aborting the hook on a parse error. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX + // shell is not safe once a path contains quotes or newlines. Post the raw + // hook payload plus metadata as form fields and let the receiver parse it. + // Timeout caps best-effort hook posts if the local listener stalls. + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +export class DevinHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getDevinConfigPath() + const scriptPath = getDevinManagedScriptPath() + const config = readDevinHooksConfig(configPath) + if (!config) { + return { + agent: 'devin', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Devin config.json' + } + } + + // Why: Report `partial` when only some managed events are registered so the + // sidebar surfaces a degraded install rather than a false-positive + // `installed`. Each DEVIN_EVENTS entry must contain the managed command for + // the integration to function end-to-end. + const command = getDevinManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const event of DEVIN_EVENTS) { + const definitions = Array.isArray(config.hooks?.[event.eventName]) + ? config.hooks![event.eventName]! + : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) + ) + if (hasCommand) { + presentCount += 1 + } else { + missing.push(event.eventName) + } + } + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { + agent: 'devin', + state, + configPath, + managedHooksPresent, + detail: mergeHookInstallDetail(detail, readConfigFromOrcaOverlapDetail(config)) + } + } + + install(): AgentHookInstallStatus { + const configPath = getDevinConfigPath() + const scriptPath = getDevinManagedScriptPath() + const config = readDevinHooksConfig(configPath) + if (!config) { + return { + agent: 'devin', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Devin config.json' + } + } + + const command = getDevinManagedCommand(scriptPath) + const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName()) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, nextConfig) + return this.getStatus() + } + + // Why: install Orca's Devin hook settings on the remote box rather than the + // local machine. Caller passes the user's SFTP handle plus the resolved + // remote `$HOME`; POSIX-only by design (Windows-remote deferred). + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths + // and a `.sh` managed script body. The remote platform is gated by the + // relay's capability RPC at a higher layer; we cannot detect it from + // `process.platform` here (that's the local box). + const remoteConfigPath = getDevinRemoteConfigPath(remoteHome) + const remoteScriptFileName = getDevinPosixManagedScriptFileName() + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` + // Why: SFTP reads/writes fail far more often than local fs (network drops, + // EACCES on remote dirs, disk full, channel closed). Wrap the entire + // install flow in try/catch so a transient I/O failure surfaces as a + // structured `state: 'error'` result for the UI, not an unstructured + // rejection the caller has to remember to handle. A `null` config + // specifically means "file present but unparseable" — keep that branch + // distinct so the user sees an actionable message. + try { + // Why: Devin config.json is JSONC (comments); stock + // JSON.parse rejects them. Read the raw text via SFTP and parse with + // jsonc-parser, mirroring the local readDevinHooksConfig path. + const body = await readTextFileRemote(sftp, remoteConfigPath) + const config = + body === null ? {} : parseDevinHooksConfigText(body, 'remote Devin config.json') + if (!config) { + return { + agent: 'devin', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Devin config.json' + } + } + + // Why: the POSIX wrapper is identical regardless of where the script + // lands; only the path differs. Reuse the same wrapper helper. + const command = getDevinRemoteManagedCommand(remoteScriptPath) + const nextConfig = applyDevinManagedHooks(config, command, remoteScriptFileName) + + // Why: write the script first, then the settings — settings.json + // referencing a missing script body would fire `command not found` on + // every tool call until the user re-runs install. Doing it in this + // order means a partial-failure mid-install at worst leaves the user + // with a working script no settings.json points at (a no-op), instead + // of broken settings.json. + // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is + // running on Windows; never derive remote script syntax from local OS. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) + + return { + agent: 'devin', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'devin', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getDevinConfigPath() + const config = readDevinHooksConfig(configPath) + if (!config) { + return { + agent: 'devin', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Devin config.json' + } + } + const { config: nextConfig, changed } = removeDevinManagedHooks( + config, + getDevinManagedScriptFileName() + ) + if (changed) { + writeHooksJson(configPath, nextConfig) + } + return this.getStatus() + } +} + +export const devinHookService = new DevinHookService() diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts new file mode 100644 index 00000000000..62cb4bd259c --- /dev/null +++ b/src/main/devin/hook-settings.ts @@ -0,0 +1,115 @@ +import { homedir } from 'os' +import { join } from 'path' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + removeManagedCommands, + wrapPosixHookCommand, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' + +const DEVIN_SCRIPT_BASE = 'devin-hook' + +export const DEVIN_EVENTS = [ + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'PostCompaction', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: Devin treats matchers as regexes and says omitted means "all"; + // Claude's "*" matcher is not a valid Devin regex. + { eventName: 'PreToolUse', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'PostToolUse', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'PermissionRequest', definition: { hooks: [{ type: 'command', command: '' }] } } +] as const + +export function getDevinConfigPath(): string { + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming') + return join(appData, 'devin', 'config.json') + } + return join(homedir(), '.config', 'devin', 'config.json') +} + +export function getDevinManagedScriptFileName(): string { + return process.platform === 'win32' ? `${DEVIN_SCRIPT_BASE}.cmd` : `${DEVIN_SCRIPT_BASE}.sh` +} + +export function getDevinPosixManagedScriptFileName(): string { + return `${DEVIN_SCRIPT_BASE}.sh` +} + +export function getDevinManagedScriptPath(): string { + return getSharedManagedScriptPath(getDevinManagedScriptFileName()) +} + +export function getDevinRemoteConfigPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.config/devin/config.json` +} + +export function getDevinManagedCommand(scriptPath: string): string { + if (process.platform === 'win32') { + // Why: Devin runs hooks through the platform shell on Windows; invoking the + // .cmd via cmd.exe preserves spaces in the shared ~/.orca script path. + return `cmd /d /s /c ""${scriptPath.replaceAll('"', '""')}""` + } + return wrapPosixHookCommand(scriptPath) +} + +export function getDevinRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function applyDevinManagedHooks( + config: HooksConfig, + command: string, + scriptFileName = getDevinManagedScriptFileName() +): HooksConfig { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + + for (const event of DEVIN_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + return { ...config, hooks: nextHooks } +} + +export function removeDevinManagedHooks( + config: HooksConfig, + scriptFileName = getDevinManagedScriptFileName() +): { + config: HooksConfig + changed: boolean +} { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + let changed = false + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + changed = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + return { + config: { ...config, hooks: nextHooks }, + changed + } +} diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 9cee0fee966..ab49c4b68f8 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -80,6 +80,9 @@ vi.mock('../copilot/hook-service', () => ({ vi.mock('../hermes/hook-service', () => ({ hermesHookService: { getStatus: vi.fn(() => ({ agent: 'hermes', state: 'absent' })) } })) +vi.mock('../devin/hook-service', () => ({ + devinHookService: { getStatus: vi.fn(() => ({ agent: 'devin', state: 'absent' })) } +})) beforeEach(() => { dropStatusEntry.mockReset() @@ -223,6 +226,17 @@ describe('agentHooks:commandCodeStatus IPC', () => { }) }) +describe('agentHooks:devinStatus IPC', () => { + it('returns Devin hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:devinStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'devin', state: 'absent' }) + }) +}) + describe('agentStatus:inferInterrupt IPC', () => { it('forwards valid inference requests to the hook server', async () => { inferInterrupt.mockReturnValue(true) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 8c08054859f..948bd596920 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -22,6 +22,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { devinHookService } from '../devin/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' type AgentStatusRuntimeEnrichment = Pick< @@ -68,6 +69,7 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment ipcMain.removeHandler('agentHooks:grokStatus') ipcMain.removeHandler('agentHooks:copilotStatus') ipcMain.removeHandler('agentHooks:hermesStatus') + ipcMain.removeHandler('agentHooks:devinStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') @@ -271,4 +273,17 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment } } }) + ipcMain.handle('agentHooks:devinStatus', (): AgentHookInstallStatus => { + try { + return devinHookService.getStatus() + } catch (err) { + return { + agent: 'devin', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bafab21edd0..d9bbc9efab8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1836,6 +1836,7 @@ export type PreloadApi = { grokStatus: () => Promise copilotStatus: () => Promise hermesStatus: () => Promise + devinStatus: () => Promise } agentTrust: { markTrusted: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index ec22788f698..1e6a7b25fc8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1680,10 +1680,12 @@ const api = { commandCodeStatus: (): Promise => ipcRenderer.invoke('agentHooks:commandCodeStatus'), grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus'), + devinStatus: (): Promise => + ipcRenderer.invoke('agentHooks:devinStatus'), copilotStatus: (): Promise => ipcRenderer.invoke('agentHooks:copilotStatus'), hermesStatus: (): Promise => - ipcRenderer.invoke('agentHooks:hermesStatus') + ipcRenderer.invoke('agentHooks:hermesStatus'), }, agentTrust: { diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 6cc08767dc7..a463222c625 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -268,7 +268,8 @@ export const getGeneralAgentSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.general.search.3c30fe2d51', 'gemini'), ...translateSearchKeyword('auto.components.settings.general.search.f472e97440', 'aider'), ...translateSearchKeyword('auto.components.settings.general.search.5d9ba08673', 'copilot'), - ...translateSearchKeyword('auto.components.settings.general.search.c61b14be7c', 'grok') + ...translateSearchKeyword('auto.components.settings.general.search.c61b14be7c', 'grok'), + ...translateSearchKeyword('auto.lib.agent.catalog.fc80296033', 'devin') ] } ]) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 637c16a8b1b..059045da5bf 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -25,6 +25,7 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record = { 'Gemini CLI': 'gemini', 'GitHub Copilot': 'copilot', Grok: 'grok', + Devin: 'devin', Antigravity: 'antigravity', OpenCode: 'opencode', Aider: 'aider', diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 8f9b6a2f822..d12d82c4e0b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -899,6 +899,7 @@ describe('agent completion coordinator', () => { 'omp', 'droid', 'grok', + 'devin', 'copilot', 'hermes' ])('recognizes %s hook agent ids even when the binary name differs', (agentType) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 0f1bc9907b2..de5bafd3d41 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6631,6 +6631,43 @@ describe('connectPanePty', () => { ) }) + it('resolves synthetic terminal titles for remote hook status updates', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-devin') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Devin' } } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'devin' }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'devin' + }) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'done', + prompt: 'finish the implementation', + agentType: 'devin' + }, + 'Devin ready' + ) + }) + it('leaves local IPC OSC 9999 status ownership in the main runtime', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-local') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index d309630633e..ce020e79934 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -95,6 +95,7 @@ import { import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -1698,7 +1699,11 @@ export function connectPanePty( // be stored against a title that was never paired with it. const currentState = useAppStore.getState() const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - currentState.setAgentStatus(cacheKey, payload, title) + currentState.setAgentStatus( + cacheKey, + payload, + resolveAgentStatusTerminalTitle(payload, title) + ) if (syncAgentTaskCompleteTrackingEnabled()) { const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] const notificationPayload = diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts new file mode 100644 index 00000000000..d0bf535eaf3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { titleHasExplicitAgentIdentity } from './title-agent-identity' + +describe('titleHasExplicitAgentIdentity', () => { + it('recognizes Devin executable titles through the shared token matcher', () => { + expect(titleHasExplicitAgentIdentity('devin.exe ready')).toBe(true) + expect(titleHasExplicitAgentIdentity('devin.cmd working')).toBe(true) + }) + + it('rejects Devin path and compound fragments', () => { + expect(titleHasExplicitAgentIdentity('C:\\work\\devin.exe\\ready')).toBe(false) + expect(titleHasExplicitAgentIdentity('devin-fixtures ready')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts index c9b00713bcb..8a496052335 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -3,9 +3,15 @@ import { isGeminiTerminalTitle, isPiTerminalTitle } from '../../../../shared/agent-detection' +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAnyLegacyAgentName +} from '../../../../shared/agent-name-token-match' -const TITLE_AGENT_TOKEN_RE = - /(? { resolveAgentStatusTerminalTitle({ agentType: 'codex', state: 'waiting' }, '\u280b Codex') ).toBe('Codex - action required') }) + + it('uses Devin synthetic titles for hook status transitions', () => { + expect( + resolveAgentStatusTerminalTitle({ agentType: 'devin', state: 'done' }, '\u280b Devin') + ).toBe('Devin ready') + expect( + resolveAgentStatusTerminalTitle({ agentType: 'devin', state: 'waiting' }, '\u280b Devin') + ).toBe('Devin - action required') + }) }) diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 62e02646501..0b6bdd30eee 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -232,6 +232,13 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('Hermes working')).toBe('working') }) + it('classifies synthesized Devin titles', () => { + expect(detectAgentStatusFromTitle('⠋ Devin')).toBe('working') + expect(detectAgentStatusFromTitle('Devin ready')).toBe('idle') + expect(detectAgentStatusFromTitle('Devin - action required')).toBe('permission') + expect(detectAgentStatusFromTitle('Devin working')).toBe('working') + }) + it('does not treat Factory Droid native needs-input titles as completion', () => { expect(detectAgentStatusFromTitle('Factory Droid needs input')).toBeNull() expect(detectAgentStatusFromTitle('Factory Droid needs your input')).toBeNull() @@ -430,6 +437,8 @@ describe('getAgentLabel', () => { expect(getAgentLabel('Droid ready')).toBe('Droid') expect(getAgentLabel('⠋ Hermes')).toBe('Hermes') expect(getAgentLabel('Hermes ready')).toBe('Hermes') + expect(getAgentLabel('⠋ Devin')).toBe('Devin') + expect(getAgentLabel('Devin ready')).toBe('Devin') }) it('does not label the Claude agents management title', () => { @@ -456,6 +465,7 @@ describe('getAgentLabel', () => { expect(getAgentLabel('~/projects/codex-scratch')).toBeNull() expect(getAgentLabel('~/cursor-rules')).toBeNull() expect(getAgentLabel('grok-fixtures')).toBeNull() + expect(getAgentLabel('devin-fixtures')).toBeNull() expect(getAgentLabel('aider-config')).toBeNull() }) @@ -465,6 +475,7 @@ describe('getAgentLabel', () => { expect(getAgentLabel('openclaude.cmd')).toBe('OpenClaude') expect(getAgentLabel('⠋ Codex')).toBe('Codex') expect(getAgentLabel('Aider idle')).toBe('Aider') + expect(getAgentLabel('Devin working')).toBe('Devin') }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index a277127def1..8bbcf11fa86 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -18,6 +18,7 @@ const TITLE_LABEL_TO_AGENT: Partial> = { 'Gemini CLI': 'gemini', 'GitHub Copilot': 'copilot', Grok: 'grok', + Devin: 'devin', Antigravity: 'antigravity', OpenCode: 'opencode', Aider: 'aider', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 964d0100867..0e5e0b1ceaf 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2146,6 +2146,7 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { | 'grok' | 'copilot' | 'hermes' + | 'devin' ) => Promise.resolve({ agent, @@ -2166,7 +2167,8 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { commandCodeStatus: () => status('command-code'), grokStatus: () => status('grok'), copilotStatus: () => status('copilot'), - hermesStatus: () => status('hermes') + hermesStatus: () => status('hermes'), + devinStatus: () => status('devin') } } diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 4bbe8d077ec..2b5ea80e619 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -160,13 +160,9 @@ function containsBrailleSpinner(title: string): boolean { return false } -function containsLegacyAgentName(title: string): boolean { - return titleHasAnyLegacyAgentName(title) -} - function containsAgentName(title: string): boolean { return ( - containsLegacyAgentName(title) || + titleHasAnyLegacyAgentName(title) || AGY_AGENT_NAME_RE.test(title) || DROID_AGENT_NAME_RE.test(title) || HERMES_AGENT_NAME_RE.test(title) @@ -381,6 +377,9 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'grok')) { return 'Grok' } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { return 'Antigravity' } @@ -468,7 +467,7 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) - const hasLegacyAgentName = containsLegacyAgentName(title) + const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { if (containsAny(title, ['action required', 'permission', 'waiting'])) { return 'permission' diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 38af603ce29..f71f1b0f660 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -383,6 +383,40 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.lastAssistantMessage).toBeUndefined() }) + it('normalizes Devin documented lifecycle events', () => { + const started = normalizeHookPayload( + state, + 'devin', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', source: 'resume' } + }, + 'production' + ) + const compacted = normalizeHookPayload( + state, + 'devin', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PostCompaction', summary: 'trimmed' } + }, + 'production' + ) + const ended = normalizeHookPayload( + state, + 'devin', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionEnd', reason: 'complete' } + }, + 'production' + ) + + expect(started?.payload).toMatchObject({ agentType: 'devin', state: 'working' }) + expect(compacted?.payload).toMatchObject({ agentType: 'devin', state: 'working' }) + expect(ended?.payload).toMatchObject({ agentType: 'devin', state: 'done' }) + }) + it('rejects oversized paneKey', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 8fae9c02995..f507aa921ab 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1832,6 +1832,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { } case 'hermes': return eventName === 'pre_llm_call' || eventName === 'on_session_start' + case 'devin': + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' } } @@ -1916,6 +1918,8 @@ function extractToolFields( return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) case 'hermes': return extractHermesToolFields(eventName, hookPayload) + case 'devin': + return extractClaudeToolFields(eventName, hookPayload) } } @@ -1967,6 +1971,58 @@ function normalizeClaudeEvent( ) } +// Why: Devin uses Claude-compatible hook payload shapes but has its own +// documented lifecycle event set. Keep attribution as Devin while normalizing +// those event names into Orca's shared status states. +function normalizeDevinEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostCompaction' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' || eventName === 'SessionEnd' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('devin', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('devin', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('devin', eventName) + }), + agentType: 'devin', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + function normalizeGeminiEvent( state: HookListenerState, eventName: unknown, @@ -2904,6 +2960,9 @@ export function normalizeHookPayload( case 'hermes': payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayloadRecord) break + case 'devin': + payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break } // Why: connectionId stays null at the listener layer. The local server keeps @@ -2956,7 +3015,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/command-code': 'command-code', '/hook/grok': 'grok', '/hook/copilot': 'copilot', - '/hook/hermes': 'hermes' + '/hook/hermes': 'hermes', + '/hook/devin': 'devin' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 9ef644fb1cd..dd4b9c9a673 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -46,6 +46,7 @@ export type AgentHookSource = | 'grok' | 'copilot' | 'hermes' + | 'devin' /** Env marker used by the remote relay. It is a transport/location marker, not * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 01f4c97ce45..1410c11d3ed 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -15,7 +15,8 @@ export const AGENT_HOOK_TARGETS = [ 'command-code', 'grok', 'copilot', - 'hermes' + 'hermes', + 'devin' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index 66bec82d790..c99d2dc3bcb 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -24,7 +24,8 @@ export const AGENT_NAMES = [ 'opencode', 'openclaw', 'aider', - 'grok' + 'grok', + 'devin' ] // Why: Windows agent titles can surface launcher process names such as diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 18e76809301..720f4a48c8b 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest' import { extractAgentProviderSession, getAgentResumeArgv, + isResumableTuiAgent, normalizeAgentProviderSession } from './agent-session-resume' describe('agent session resume metadata', () => { + it('treats devin as a resumable TUI agent', () => { + expect(isResumableTuiAgent('devin')).toBe(true) + }) + it.each([ ['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }], ['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }], @@ -17,7 +22,8 @@ describe('agent session resume metadata', () => { ], ['opencode', { sessionID: 'opencode-session' }, { key: 'session_id', id: 'opencode-session' }], ['droid', { session_id: 'droid-session' }, { key: 'session_id', id: 'droid-session' }], - ['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }] + ['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }], + ['devin', { session_id: 'devin-session' }, { key: 'session_id', id: 'devin-session' }] ] as const)('extracts %s provider session ids', (source, payload, expected) => { expect(extractAgentProviderSession(source, payload)).toEqual(expected) }) @@ -29,7 +35,8 @@ describe('agent session resume metadata', () => { ['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']], ['opencode', { key: 'session_id', id: 's1' }, ['opencode', '--session', 's1']], ['droid', { key: 'session_id', id: 's1' }, ['droid', '--resume', 's1']], - ['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']] + ['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']], + ['devin', { key: 'session_id', id: 'abc12345' }, ['devin', '--resume', 'abc12345']] ] as const)('builds %s resume argv', (agent, providerSession, expected) => { expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected) }) @@ -44,4 +51,8 @@ describe('agent session resume metadata', () => { id: 'ok' }) }) + + it('rejects devin resume when provider session key is not session_id', () => { + expect(getAgentResumeArgv('devin', { key: 'conversation_id', id: 'x' })).toBeNull() + }) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index d092a20aa3b..044f2d0f727 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -9,7 +9,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'antigravity', 'opencode', 'droid', - 'grok' + 'grok', + 'devin' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -122,6 +123,10 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['sessionId', 'session_id']) return id ? { key: 'session_id', id } : null } + case 'devin': { + const id = readSessionId(payload, ['session_id', 'sessionId']) + return id ? { key: 'session_id', id } : null + } case 'amp': case 'cursor': case 'pi': @@ -153,5 +158,7 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['droid', '--resume', id] : null case 'grok': return providerSession.key === 'session_id' ? ['grok', '--resume', id] : null + case 'devin': + return providerSession.key === 'session_id' ? ['devin', '--resume', id] : null } } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 05d2a1740f1..e33aab8ac37 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -13,6 +13,7 @@ export const AI_VAULT_AGENTS = [ 'opencode', 'grok', 'openclaw', + 'devin', 'droid' ] as const satisfies readonly TuiAgent[] @@ -33,6 +34,7 @@ export const AI_VAULT_AGENT_LABELS = { opencode: 'OpenCode', grok: 'Grok', openclaw: 'OpenClaw', + devin: 'Devin', droid: 'Droid' } as const satisfies Record @@ -144,6 +146,7 @@ function buildAgentResumeInvocation( case 'gemini': case 'grok': case 'hermes': + case 'devin': case 'openclaw': case 'droid': return `${baseCommand} --resume ${sessionArg}` diff --git a/src/shared/synthetic-agent-title.test.ts b/src/shared/synthetic-agent-title.test.ts index 877bbd594f4..e4b80521e05 100644 --- a/src/shared/synthetic-agent-title.test.ts +++ b/src/shared/synthetic-agent-title.test.ts @@ -14,4 +14,10 @@ describe('synthetic agent titles', () => { expect(shouldDriveSyntheticAgentTitleFromHook('codex', 'working')).toBe(false) expect(shouldDriveSyntheticAgentTitleFromHook('codex', 'done')).toBe(true) }) + + it('provides Devin titles for hook-driven status updates', () => { + expect(getSyntheticAgentTerminalTitle('devin', 'done')).toBe('Devin ready') + expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('devin', 'working')).toBe(true) + }) }) diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index a2071092732..d12d39c095b 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -35,6 +35,11 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record = { detectCmd: 'devin', launchCmd: 'devin', expectedProcess: 'devin', - // Why: `devin -- ` auto-submits the prompt (the issue's claim - // that it pre-fills without submitting is incorrect per the official - // docs at docs.devin.ai/cli/reference/commands). `stdin-after-start` - // launches the REPL first, then pastes via bracketed paste so the - // user can review before submitting — same as aider, goose, amp, etc. + // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli). + // `stdin-after-start` starts the REPL with no argv prompt; Orca then sends + // `followupPrompt` to the PTY as plain input + Enter after startup (not + // bracketed paste). Use `draftPrompt` / agent-paste-draft for review-before-send. promptInjectionMode: 'stdin-after-start' } } diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 800b28e0bdb..27320f51b35 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -138,6 +138,64 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves sleeping agent record origin across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'devin', + providerSession: { key: 'session_id', id: 'devin-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + origin: 'quit' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('quit') + } + }) + + it('preserves legacy live sleeping agent origins across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + origin: 'live' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') + } + }) + it('drops malformed sleeping agent resume records without failing the whole session', () => { const result = parseWorkspaceSession({ activeRepoId: null,