From 08ba730d8e3ebd4f83eaa346bd8f90242ecb723d Mon Sep 17 00:00:00 2001 From: "Yang,Zhou" Date: Thu, 18 Jun 2026 11:49:29 +0800 Subject: [PATCH] feat(devin): managed hooks, sleeping resume, AI Vault (#5380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(terminal): add proportional scroll fallback for sidebar resize" (#937) * fix(sidebar): smoothly animate off-screen worktree reveal on click (#1302) Clicking a worktree card whose row lies outside the sidebar viewport caused an instant jump when scrolling it into view. Switching `scrollToIndex` to `behavior: 'smooth'` turns that minimum-distance scroll into an animated slide while keeping `align: 'auto'` so visible cards still no-op (no re-centering). Co-authored-by: Orca * Avoid local scrollback serialization on shutdown (#1821) * Fix PR refresh coordinator test arguments (#2545) * release: v1.4.31 * release: v1.4.31 * release: v1.4.31 * release: v1.4.31 * release: v1.4.31 * release: v1.4.31 * release: v1.4.31 * release: v1.4.36-rc.6 * release: v1.4.36-rc.6 * release: v1.4.36-rc.6 * ci: gate release-cut to the canonical repo so it skips forks (#4815) The cut job checks out main, bumps package.json's version, and fast-forwards main. On a fork with Actions enabled, the scheduled RC cut runs against the fork's main and diverges it on the version line every slot, so that contributor's PRs back to upstream conflict on package.json even when their change never touches it. Gate the job to github.repository == 'stablyai/orca' so it (and the jobs that depend on it) no-op on forks. Canonical scheduled and manual cuts are unaffected. * feat(hooks): install Devin managed status hooks * feat(devin): address hook review, resume, and UI polish - Parse Devin config.json as JSONC; warn on read_config_from overlap - Windows hook command uses forward slashes; APPDATA fallback - Add devin to sleeping-agent resume and UI registries (plan 003/004) - Add hook-service and hook-config-json tests Closes follow-up for plans 002–004 on feat/add-devin-agent. * feat(devin): scan ATIF transcripts for AI Vault Register devin in AI_VAULT_AGENTS, discover ~/.local/share/devin/cli/transcripts (or DEVIN_HOME), parse ATIF JSON sessions, and build devin --resume commands. * docs(devin): clarify stdin-after-start vs bracketed paste * fix(devin): use JSONC for remote install, add partial+APPDATA tests - installRemote: replace readHooksJsonRemote (JSON.parse) with readTextFileRemote + parseJsonc for JSONC compatibility on SSH - Add partial status test (some hooks missing → state:'partial') - Add Windows APPDATA config path test with fallback * fix(devin): address CodeRabbit review — sessionId fallback, parseJsonc errors, comment, i18n * Fix Devin integration edge cases Co-authored-by: Orca * Package Devin JSONC parser dependency Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Trevin Chow Co-authored-by: Jinwoo-H --- config/packaged-runtime-node-modules.cjs | 1 + config/tsconfig.cli.json | 3 + package.json | 1 + pnpm-lock.yaml | 3 + .../managed-agent-hook-controls.ts | 12 +- .../remote-hook-service-installers.test.ts | 65 ++++- .../remote-managed-hook-installers.ts | 4 +- .../ai-vault/session-scanner-agent-parser.ts | 54 ++++ .../session-scanner-devin-parser.test.ts | 95 +++++++ .../ai-vault/session-scanner-devin-parser.ts | 115 ++++++++ src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.test.ts | 24 ++ src/main/ai-vault/session-scanner.ts | 62 +--- src/main/claude/hook-service.test.ts | 10 +- src/main/claude/hook-service.ts | 32 ++- src/main/cli/packaged-cli-assets.test.ts | 1 + src/main/devin/hook-config-json.test.ts | 91 ++++++ src/main/devin/hook-config-json.ts | 72 +++++ src/main/devin/hook-service.test.ts | 205 ++++++++++++++ src/main/devin/hook-service.ts | 268 ++++++++++++++++++ src/main/devin/hook-settings.ts | 115 ++++++++ src/main/ipc/agent-hooks.test.ts | 14 + src/main/ipc/agent-hooks.ts | 15 + src/preload/api-types.ts | 1 + src/preload/index.ts | 4 +- .../src/components/settings/general-search.ts | 3 +- .../worktree-title-derived-agent-rows.ts | 1 + .../agent-completion-coordinator.test.ts | 1 + .../terminal-pane/pty-connection.test.ts | 37 +++ .../terminal-pane/pty-connection.ts | 7 +- .../title-agent-identity.test.ts | 14 + .../terminal-pane/title-agent-identity.ts | 18 +- .../lib/agent-status-terminal-title.test.ts | 9 + src/renderer/src/lib/agent-status.test.ts | 11 + src/renderer/src/lib/use-tab-agent.ts | 1 + src/renderer/src/web/web-preload-api.ts | 4 +- src/shared/agent-detection.ts | 11 +- src/shared/agent-hook-listener.test.ts | 34 +++ src/shared/agent-hook-listener.ts | 62 +++- src/shared/agent-hook-relay.ts | 1 + src/shared/agent-hook-types.ts | 3 +- src/shared/agent-name-token-match.ts | 3 +- src/shared/agent-session-resume.test.ts | 15 +- src/shared/agent-session-resume.ts | 9 +- src/shared/ai-vault-types.ts | 3 + src/shared/synthetic-agent-title.test.ts | 6 + src/shared/synthetic-agent-title.ts | 5 + src/shared/tui-agent-config.ts | 9 +- src/shared/workspace-session-schema.test.ts | 58 ++++ 49 files changed, 1510 insertions(+), 83 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-agent-parser.ts create mode 100644 src/main/ai-vault/session-scanner-devin-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-devin-parser.ts create mode 100644 src/main/devin/hook-config-json.test.ts create mode 100644 src/main/devin/hook-config-json.ts create mode 100644 src/main/devin/hook-service.test.ts create mode 100644 src/main/devin/hook-service.ts create mode 100644 src/main/devin/hook-settings.ts create mode 100644 src/renderer/src/components/terminal-pane/title-agent-identity.test.ts 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,