mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(devin): managed hooks, sleeping resume, AI Vault (#5380)
* 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 <help@stably.ai> * 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 <help@stably.ai> * Package Devin JSONC parser dependency Co-authored-by: Orca <help@stably.ai> --------- 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 <help@stably.ai> 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 <trevin@trevinchow.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
co-authored by
Orca
Neil
Brennan Benson
Jinjing
github-actions[bot]
Trevin Chow
Jinwoo-H
parent
53fe9b4ff1
commit
08ba730d8e
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, { hooks: { command: string }[] }[]>
|
||||
@@ -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<string, { matcher?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AiVaultSession | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<AiVaultSession | null> {
|
||||
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, unknown>): string | null {
|
||||
const message = asRecord(step.message)
|
||||
if (message) {
|
||||
return extractContentText(message.content) ?? extractString(message.content)
|
||||
}
|
||||
return extractString(step.text)
|
||||
}
|
||||
|
||||
function devinStepTokenTotal(
|
||||
metadata: Record<string, unknown> | null,
|
||||
metrics: Record<string, unknown> | 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<string, unknown> | null,
|
||||
metrics: Record<string, unknown> | 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
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type AiVaultScanOptions = {
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
grokSessionsDir?: string
|
||||
devinTranscriptsDir?: string
|
||||
hermesSessionsDir?: string
|
||||
rovoSessionsDir?: string
|
||||
openclawStateDir?: string
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <DEVIN_HOME>/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<AiVaultSession | null> {
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}'),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<string, { matcher?: string; hooks: { command: string }[] }[]>
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<AgentHookInstallStatus> {
|
||||
// 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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1836,6 +1836,7 @@ export type PreloadApi = {
|
||||
grokStatus: () => Promise<AgentHookInstallStatus>
|
||||
copilotStatus: () => Promise<AgentHookInstallStatus>
|
||||
hermesStatus: () => Promise<AgentHookInstallStatus>
|
||||
devinStatus: () => Promise<AgentHookInstallStatus>
|
||||
}
|
||||
agentTrust: {
|
||||
markTrusted: (args: {
|
||||
|
||||
@@ -1680,10 +1680,12 @@ const api = {
|
||||
commandCodeStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:commandCodeStatus'),
|
||||
grokStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:grokStatus'),
|
||||
devinStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:devinStatus'),
|
||||
copilotStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:copilotStatus'),
|
||||
hermesStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:hermesStatus')
|
||||
ipcRenderer.invoke('agentHooks:hermesStatus'),
|
||||
},
|
||||
|
||||
agentTrust: {
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -25,6 +25,7 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 =
|
||||
/(?<![\w./\\-])(claude|openclaude|codex|gemini|antigravity|agy|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
|
||||
const EXTRA_TITLE_AGENT_TOKEN_RE =
|
||||
/(?<![\w./\\-])(?:cursor-agent|pi)(?:\.(?:exe|cmd|bat|ps1))?(?![\w./\\-])/i
|
||||
|
||||
export function titleHasExplicitAgentIdentity(title: string): boolean {
|
||||
if (!title) {
|
||||
@@ -20,7 +26,13 @@ export function titleHasExplicitAgentIdentity(title: string): boolean {
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return TITLE_AGENT_TOKEN_RE.test(title)
|
||||
return (
|
||||
titleHasAnyLegacyAgentName(title) ||
|
||||
AGY_AGENT_NAME_RE.test(title) ||
|
||||
DROID_AGENT_NAME_RE.test(title) ||
|
||||
HERMES_AGENT_NAME_RE.test(title) ||
|
||||
EXTRA_TITLE_AGENT_TOKEN_RE.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
export function titleIsInconclusiveNativeDroidTitle(title: string): boolean {
|
||||
|
||||
@@ -49,4 +49,13 @@ describe('resolveAgentStatusTerminalTitle', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
|
||||
@@ -2146,6 +2146,7 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
||||
| 'grok'
|
||||
| 'copilot'
|
||||
| 'hermes'
|
||||
| 'devin'
|
||||
) =>
|
||||
Promise.resolve({
|
||||
agent,
|
||||
@@ -2166,7 +2167,8 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
||||
commandCodeStatus: () => status('command-code'),
|
||||
grokStatus: () => status('grok'),
|
||||
copilotStatus: () => status('copilot'),
|
||||
hermesStatus: () => status('hermes')
|
||||
hermesStatus: () => status('hermes'),
|
||||
devinStatus: () => status('devin')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): 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<Record<string, AgentHookSource>>
|
||||
'/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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AiVaultAgent, string>
|
||||
|
||||
@@ -144,6 +146,7 @@ function buildAgentResumeInvocation(
|
||||
case 'gemini':
|
||||
case 'grok':
|
||||
case 'hermes':
|
||||
case 'devin':
|
||||
case 'openclaw':
|
||||
case 'droid':
|
||||
return `${baseCommand} --resume ${sessionArg}`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,11 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
|
||||
workingLabel: 'Hermes',
|
||||
permissionLabel: 'Hermes - action required',
|
||||
idleLabel: 'Hermes ready'
|
||||
},
|
||||
devin: {
|
||||
workingLabel: 'Devin',
|
||||
permissionLabel: 'Devin - action required',
|
||||
idleLabel: 'Devin ready'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,11 +315,10 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
||||
detectCmd: 'devin',
|
||||
launchCmd: 'devin',
|
||||
expectedProcess: 'devin',
|
||||
// Why: `devin -- <prompt>` 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 -- <prompt>` 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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user