From ccdb324b63e4942891942edde1216586a51774bd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:11:25 -0700 Subject: [PATCH] Add CodeBuddy as a built-in coding agent (#23740) * feat(agents): integrate CodeBuddy launch, status and session history * docs: record CodeBuddy lifecycle verification * fix(codebuddy): backfill scoped history and negotiate remote resume * test(cli): include CodeBuddy in known search agents --- README.md | 1 + config/tsconfig.cli.json | 1 + docs/reference/codebuddy-harness.md | 44 ++++ docs/site/content/docs/agents/supported.mdx | 1 + .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 1 + src/cli/search-command-arguments.test.ts | 2 +- .../managed-agent-hook-registry.ts | 5 + .../managed-hook-command-contract.test.ts | 17 +- ...ote-hook-service-registry-coverage.test.ts | 2 + .../remote-managed-hook-installers.ts | 2 + .../server-retired-pane-new-turn.test.ts | 1 + .../remote-session-scanner-sources.ts | 12 + .../session-cwd-bucket-layouts.test.ts | 18 +- .../ai-vault/session-cwd-bucket-layouts.ts | 27 +- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 10 + .../session-scanner-codebuddy-parser.ts | 230 ++++++++++++++++++ .../session-scanner-codebuddy-scope.test.ts | 135 ++++++++++ .../session-scanner-codebuddy-source.test.ts | 80 ++++++ .../session-scanner-log-agent-fixtures.ts | 17 ++ .../ai-vault/session-scanner-parse-cache.ts | 3 + .../session-scanner-scope-discovery.ts | 6 +- .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.ts | 12 +- src/main/claude/hook-settings.ts | 4 +- src/main/codebuddy/hook-service.test.ts | 47 ++++ src/main/codebuddy/hook-service.ts | 34 +++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/agent-catalog-platform.ts | 13 + src/renderer/src/lib/agent-catalog.tsx | 22 +- src/renderer/src/lib/agent-status.ts | 1 + ...t-resume-host-authority-capability.test.ts | 2 + .../agent-resume-host-authority-capability.ts | 2 + .../remote-agent-session-launch.test.ts | 40 +-- .../codebuddy-question-hooks.jsonl | 6 + .../codebuddy-question-hooks.meta.json | 6 + src/shared/agent-headless-command.ts | 2 + .../agent-hook-listener/provider-dispatch.ts | 11 + .../provider-event-routing.ts | 2 + .../providers/compatible-lifecycle-events.ts | 104 ++++++++ .../providers/qoder-events.ts | 82 +------ .../agent-hook-listener/source-routing.ts | 1 + src/shared/agent-hook-relay.ts | 1 + src/shared/agent-hook-types.ts | 1 + src/shared/agent-icons/codebuddy.png | Bin 0 -> 3794 bytes src/shared/agent-kind.ts | 1 + src/shared/agent-node-package-entrypoints.ts | 1 + .../agent-session-option-catalog-codebuddy.ts | 44 ++++ src/shared/agent-session-option-catalog.ts | 2 + src/shared/agent-session-resume.test.ts | 5 + src/shared/agent-session-resume.ts | 4 + src/shared/agent-type-label.ts | 1 + src/shared/ai-vault-resume-command.test.ts | 11 + src/shared/ai-vault-resume-command.ts | 1 + src/shared/ai-vault-session-deletion.ts | 1 + src/shared/ai-vault-types.ts | 2 + src/shared/codebuddy-agent.test.ts | 67 +++++ src/shared/codebuddy-headless-command.ts | 13 + src/shared/protocol-version.ts | 3 + src/shared/skills-cli-agent-keys.ts | 1 + src/shared/telemetry-property-schemas.ts | 1 + src/shared/tui-agent-config.ts | 6 +- src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-permissions.ts | 1 + src/shared/tui-agent-rest-signal.test.ts | 1 + src/shared/tui-agent-selection.ts | 3 +- src/shared/tui-agent.ts | 1 + 74 files changed, 1074 insertions(+), 121 deletions(-) create mode 100644 docs/reference/codebuddy-harness.md create mode 100644 src/main/ai-vault/session-scanner-codebuddy-parser.ts create mode 100644 src/main/ai-vault/session-scanner-codebuddy-scope.test.ts create mode 100644 src/main/ai-vault/session-scanner-codebuddy-source.test.ts create mode 100644 src/main/codebuddy/hook-service.test.ts create mode 100644 src/main/codebuddy/hook-service.ts create mode 100644 src/renderer/src/lib/agent-catalog-platform.ts create mode 100644 src/shared/__fixtures__/codebuddy-question-hooks.jsonl create mode 100644 src/shared/__fixtures__/codebuddy-question-hooks.meta.json create mode 100644 src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts create mode 100644 src/shared/agent-icons/codebuddy.png create mode 100644 src/shared/agent-session-option-catalog-codebuddy.ts create mode 100644 src/shared/codebuddy-agent.test.ts create mode 100644 src/shared/codebuddy-headless-command.ts diff --git a/README.md b/README.md index 0f337f9e677..93e09614609 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Autohand Code logo Autohand Code   Charm logo Charm   Cline logo Cline   + CodeBuddy logo CodeBuddy   Codebuff logo Codebuff   Freebuff logo Freebuff   Command Code logo Command Code   diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 030a90632b0..85f3529b2dc 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -9,6 +9,7 @@ "../src/main/claude/claude-hook-event-versions.ts", "../src/main/claude/claude-managed-hook-events.ts", "../src/main/qoder/hook-service.ts", + "../src/main/codebuddy/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", diff --git a/docs/reference/codebuddy-harness.md b/docs/reference/codebuddy-harness.md new file mode 100644 index 00000000000..e59eaec855e --- /dev/null +++ b/docs/reference/codebuddy-harness.md @@ -0,0 +1,44 @@ +# CodeBuddy terminal harness + +CodeBuddy uses its own identity in the desktop and mobile agent catalogs, process +detection, telemetry, hooks, resume records and AI Vault. `codebuddy` and `cbc` +identify the interactive CLI; print, server, ACP and detached background modes do +not identify an interactive pane. Model and effort selections use `--model` and +`--effort`; the CLI resolves its stable model aliases for the current account. + +Managed hooks use the existing Claude-compatible installer and transport, with +CodeBuddy's own `.codebuddy/settings.json` and hook source. Installation preserves +user hooks and statusline configuration. Local and SSH installers share the same +plan; Windows explicitly selects CodeBuddy's supported PowerShell hook shell. +Status flows through the execution host's canonical hook store. + +## Observed lifecycle + +Verified with the authenticated CodeBuddy 2.159.0 CLI on macOS: + +- `UserPromptSubmit` starts work. `SessionStart` can arrive afterward and must not + settle that work. +- An unanswered `AskUserQuestion` emits a `Notification` with + `notification_type: permission_prompt` and the message + `needs your permission to use AskUserQuestion`. +- In this version, `PreToolUse(AskUserQuestion)` arrives **after** the user answers; + it resumes working, followed by `PostToolUse` and `Stop`. +- `Stop` supplies the final assistant message and the provider session identity + supplies the resume command. + +The sanitized real event sequence is +`src/shared/__fixtures__/codebuddy-question-hooks.jsonl`. The regression test +replays it through the shared hook listener. Qoder keeps its existing lifecycle +semantics while sharing the common event projection. + +AI Vault reads CodeBuddy's `type: message` JSONL records with top-level roles and +`input_text` / `output_text` content blocks. Local, WSL and SSH discovery use the +provider's `.codebuddy/projects` tree and the same incremental parser. + +## Validation scope + +Live macOS checks exercised launch through Orca's agent menu, model switching, +question waiting, answer submission, working and completion indicators, history +discovery and a resumed session recalling its earlier answer. Hidden-renderer CDP +screenshots record the working, question and completed states. Windows, Linux, +WSL and SSH runtime execution have not been exercised live on this machine. diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 0658ce6fd8c..ecc8cfd1305 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -45,6 +45,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Auggie | Auto-setup | [Augment](https://docs.augmentcode.com/cli/overview) | | Autohand | Auto-setup | [Autohand](https://github.com/autohandai/code-cli) | | Cline | Auto-setup | [Cline](https://docs.cline.bot/cline-cli/overview) | +| CodeBuddy | `npm install -g @tencent-ai/codebuddy-code` | [CodeBuddy](https://www.codebuddy.ai/cli) | | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | | Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 8d0a6d474f7..53745ead77e 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -18,6 +18,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial = TUI_AGENT_DISPL export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { openclaude: 'openclaude.gitlawb.com', + codebuddy: 'codebuddy.ai', grok: 'x.ai', copilot: 'github.com', opencode: 'opencode.ai', diff --git a/src/cli/search-command-arguments.test.ts b/src/cli/search-command-arguments.test.ts index be9195a1fab..27b06286f52 100644 --- a/src/cli/search-command-arguments.test.ts +++ b/src/cli/search-command-arguments.test.ts @@ -126,7 +126,7 @@ describe('orca search argument parsing', () => { it('rejects an unknown --agent and names the known ones', () => { expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow( - /Unknown --agent "bogus"\. Known agents: claude, codex, / + /Unknown --agent "bogus"\. Known agents: claude, codebuddy, codex, / ) }) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 31513658035..11b642ee090 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { qoderHookService } from '../qoder/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { HookInstallAgent } from '../../shared/telemetry-events' @@ -46,6 +47,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['qoder', () => qoderHookService.install()], + ['codebuddy', () => codebuddyHookService.install()], ['antigravity', () => antigravityHookService.install()], ['amp', () => ampHookService.install()], ['cursor', () => cursorHookService.install()], @@ -73,6 +75,7 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['codex', () => codexHookService.refreshManagedScripts()], ['gemini', () => geminiHookService.refreshManagedScripts()], ['qoder', () => qoderHookService.refreshManagedScripts()], + ['codebuddy', () => codebuddyHookService.refreshManagedScripts()], ['antigravity', () => antigravityHookService.refreshManagedScripts()], ['cursor', () => cursorHookService.refreshManagedScripts()], ['droid', () => droidHookService.refreshManagedScripts()], @@ -92,6 +95,7 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['qoder', () => qoderHookService.remove()], + ['codebuddy', () => codebuddyHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['amp', () => ampHookService.remove()], ['cursor', () => cursorHookService.remove()], @@ -117,6 +121,7 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['qoder', () => qoderHookService.getStatus()], + ['codebuddy', () => codebuddyHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], ['amp', () => ampHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index b3166a6c90e..ebb1412ec58 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -96,6 +96,20 @@ const buildersByAgent = new Map([ remote: (path) => [getClaudeRemoteCommand(path)] } ], + [ + 'codebuddy', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.codebuddy', + scriptBaseName: 'codebuddy-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], [ 'codex', { @@ -224,7 +238,8 @@ describe('managed hook command contract', () => { const scannedCommand = platform === 'win32' && ((agent === 'codex' && command.startsWith('if (Test-Path')) || - (agent === 'qoder' && command.startsWith('$scriptPath = Join-Path'))) + ((agent === 'qoder' || agent === 'codebuddy') && + command.startsWith('$scriptPath = Join-Path'))) ? command .replaceAll('$LASTEXITCODE', '') .replaceAll('$env:', '') diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index f9ff9c232e0..dea6c4a4663 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -53,6 +54,7 @@ describe('remote hook service registry coverage', () => { ['kimi', kimiHookService], ['muse', museHookService], ['qoder', qoderHookService], + ['codebuddy', codebuddyHookService], ['zcode', zcodeHookService], ['dsh', dshHookService] ]) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 272e330be11..b012af91ff3 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { qoderHookService } from '../qoder/hook-service' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types' @@ -64,6 +65,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)], + ['codebuddy', (sftp, remoteHome) => codebuddyHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index a405199a6e7..23dbb56c695 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -25,6 +25,7 @@ const NEW_TURN_EVENT: Record = { qoder: 'SessionStart', claude: 'SessionStart', kimi: 'UserPromptSubmit', + codebuddy: 'UserPromptSubmit', codex: 'SessionStart', gemini: 'BeforeAgent', antigravity: 'PreInvocation', diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index ed34e8b52a5..dfd012415d5 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -8,6 +8,7 @@ import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' +import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser' import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' @@ -65,6 +66,17 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, + { + // The execution host owns CodeBuddy's transcripts, including subagent pruning. + ...jsonlSource( + 'codebuddy', + remoteHome, + hostPlatform, + ['.codebuddy', 'projects'], + parseCodebuddySessionContent + ), + partitionSubagentTranscripts: partitionSubagentTranscriptPaths + }, remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', diff --git a/src/main/ai-vault/session-cwd-bucket-layouts.test.ts b/src/main/ai-vault/session-cwd-bucket-layouts.test.ts index 38c2f0b68e2..7cbbbfbcc6d 100644 --- a/src/main/ai-vault/session-cwd-bucket-layouts.test.ts +++ b/src/main/ai-vault/session-cwd-bucket-layouts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { PI_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' +import { PI_CWD_BUCKET_LAYOUT, CODEBUDDY_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' function piDirInScope(dirName: string, scopePath: string): boolean { return PI_CWD_BUCKET_LAYOUT.isDirInScope( @@ -31,3 +31,19 @@ describe('PI_CWD_BUCKET_LAYOUT', () => { expect(piDirInScope('--C--Users-ada-repo--', 'C:\\Users\\ada\\repo')).toBe(true) }) }) + +describe('CODEBUDDY_CWD_BUCKET_LAYOUT', () => { + it.each([ + ['C:\\Users\\ada\\repo', 'C-Users-ada-repo-src'], + ['/home/ada/repo', 'home-ada-repo-src'], + ['/', 'home-ada-repo'], + [`/${'界'.repeat(100)}`, `${'界'.repeat(60)}-hash`] + ])('keeps candidate buckets for %s', (scope, bucket) => { + expect( + CODEBUDDY_CWD_BUCKET_LAYOUT.isDirInScope( + bucket, + new Set(CODEBUDDY_CWD_BUCKET_LAYOUT.encodeScopePrefixes(scope)) + ) + ).toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-cwd-bucket-layouts.ts b/src/main/ai-vault/session-cwd-bucket-layouts.ts index d8b4a565949..3f0195cdcd4 100644 --- a/src/main/ai-vault/session-cwd-bucket-layouts.ts +++ b/src/main/ai-vault/session-cwd-bucket-layouts.ts @@ -3,7 +3,7 @@ import { encodeClaudeProjectPaths, isClaudeProjectDirInScope } from './claude-pr /** An agent that keeps one directory per cwd, named by a lossy encoding of that cwd. */ export type CwdBucketLayout = { - agent: Extract + agent: Extract encodeScopePrefixes: (scopePath: string) => string[] isDirInScope: (dirName: string, prefixes: ReadonlySet) => boolean } @@ -41,3 +41,28 @@ export const PI_CWD_BUCKET_LAYOUT: CwdBucketLayout = { return false } } + +// CodeBuddy 2.159.0 collapses separators and retains 180 UTF-8 bytes before long-path hashes. +function codebuddyScopePrefix(path: string): string { + const encoded = path + .replace(/[/\\:]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + let prefix = '' + for (const character of encoded) { + if (Buffer.byteLength(prefix + character, 'utf8') > 180) { + break + } + prefix += character + } + return prefix +} + +export const CODEBUDDY_CWD_BUCKET_LAYOUT: CwdBucketLayout = { + agent: 'codebuddy', + encodeScopePrefixes: (path) => [ + ...new Set([path, path.normalize('NFC')].map(codebuddyScopePrefix)) + ], + // This is only a prefilter: each transcript's cwd decides membership, including lossy collisions. + isDirInScope: (name, prefixes) => [...prefixes].some((prefix) => name.startsWith(prefix)) +} diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 80a56bf2519..95910d208b7 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,3 +1,4 @@ +import { parseCodebuddySessionFile } from './session-scanner-codebuddy-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { parseDevinSessionFile } from './session-scanner-devin-parser' @@ -87,6 +88,8 @@ export async function parseAgentSessionFile( signal?: AbortSignal ): Promise { switch (candidate.agent) { + case 'codebuddy': + return parseCodebuddySessionFile(candidate.file, platform, messages) case 'claude': return parseClaudeSessionFile(candidate.file, platform, messages) case 'codex': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index ee10422031b..66d069b68cc 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -34,6 +34,7 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const CODEBUDDY_PROJECTS_DIR = join(homedir(), '.codebuddy', 'projects') const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = resolveAbsoluteDirOverride( @@ -114,6 +115,15 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // their parent instead. directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME }, + codebuddy: { + rootDirs: (options, wslHomeDirs) => [ + options.codebuddyProjectsDir ?? CODEBUDDY_PROJECTS_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.codebuddy', 'projects')) + ], + extensions: ['.jsonl'], + // Nested subagent transcripts are not independent top-level conversations. + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, codex: { rootDirs: (options, wslHomeDirs) => uniqueCodexSessionsDirs([ diff --git a/src/main/ai-vault/session-scanner-codebuddy-parser.ts b/src/main/ai-vault/session-scanner-codebuddy-parser.ts new file mode 100644 index 00000000000..94fc52d1eab --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-parser.ts @@ -0,0 +1,230 @@ +import { createInterface } from 'node:readline' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import type { FileWithMtime, ResumableSessionParseState } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' +import { + addPreviewMessage, + accumulatorSessionIdentity, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateLatestLocation, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractString, + normalizeTitleText, + parseJsonObject +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +// CodeBuddy stores top-level message roles and input_text/output_text blocks, not Claude records. +export type CodebuddySessionParseState = { + accumulator: ReturnType + aiTitle: string | null + firstUserTitle: string | null +} + +export function createCodebuddySessionParseState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): CodebuddySessionParseState { + return { + accumulator: createAccumulator({ + agent: 'codebuddy', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), + aiTitle: null, + firstUserTitle: null + } +} + +export function cloneCodebuddySessionParseState( + state: CodebuddySessionParseState +): CodebuddySessionParseState { + return { + accumulator: { + ...state.accumulator, + previewMessages: [...state.accumulator.previewMessages] + }, + aiTitle: state.aiTitle, + firstUserTitle: state.firstUserTitle + } +} + +// CodeBuddy conversation content is an array of typed text blocks; join the +// text ones. Non-text blocks (tool calls, diffs) carry no preview copy. +function codebuddyContentText(content: unknown): string | null { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return null + } + const parts = content + .map((block) => { + const record = asRecord(block) + const type = extractString(record?.type) + return type === 'input_text' || type === 'output_text' || type === 'text' + ? extractString(record?.text) + : null + }) + .filter((text): text is string => typeof text === 'string' && text.length > 0) + return parts.length > 0 ? parts.join('\n\n') : null +} + +export function consumeCodebuddySessionLine(state: CodebuddySessionParseState, line: string): void { + const { accumulator } = state + const record = parseJsonObject(line) + if (!record) { + return + } + + if (typeof record.sessionId === 'string' && record.sessionId.trim()) { + accumulator.sessionId = record.sessionId.trim() + } + updateTimeline(accumulator, record.timestamp) + updateLatestLocation(accumulator, record) + + // The CLI's own session title; later summaries revise it, like Claude's + // custom-title. + if (record.type === 'summary') { + const summary = normalizeTitleText(extractString(record.summary) ?? '') + if (summary) { + accumulator.title = summary + } + return + } + + if (record.type === 'ai-title') { + const title = normalizeTitleText(extractString(record.aiTitle) ?? '') + if (title) { + // CodeBuddy can revise generated names; AI Vault mirrors the current one. + state.aiTitle = title + } + return + } + + if (record.type !== 'message') { + return + } + + const role = extractString(record.role) + const text = codebuddyContentText(record.content) + accumulator.messageCount++ + if (role === 'user') { + // Meta prompts (injected context) only seed the last-resort title. + // `providerData.skipRun` marks machinery-injected turns that never reached + // the model as a user prompt. + const isMetaUserTurn = + asRecord(record.providerData)?.skipRun === true || + (text != null && isKnownHarnessInjectedUserTurnText(text)) + addPreviewMessage(accumulator, { + role: 'user', + text, + timestamp: record.timestamp, + seedFirstUserPrompt: !isMetaUserTurn + }) + if (text && !isMetaUserTurn) { + state.firstUserTitle ??= text + } + return + } + if (role === 'assistant') { + addPreviewMessage(accumulator, { role: 'assistant', text, timestamp: record.timestamp }) + const model = extractString(asRecord(record.providerData)?.model) + if (model) { + accumulator.model = model + } + } +} + +export function finalizeCodebuddySessionParseState( + state: CodebuddySessionParseState, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + // Finalize a snapshot: the live state (and its preview array) may keep + // accumulating appended lines after this session object is handed out. + const snapshot = cloneCodebuddySessionParseState(state) + // Why: the CLI's summary title wins via accumulator.title; the generated + // ai-title should outrank the raw first prompt when present. + snapshot.accumulator.fallbackTitle = snapshot.aiTitle ?? snapshot.firstUserTitle + return finalizeSession(snapshot.accumulator, platform, options) +} + +function codebuddyResumeStateFromParseState( + state: CodebuddySessionParseState +): ResumableSessionParseState { + return { + consumeLine: (line) => consumeCodebuddySessionLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => codebuddyResumeStateFromParseState(cloneCodebuddySessionParseState(state)), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => finalizeCodebuddySessionParseState(state, platform, options) + } +} + +export function createCodebuddySessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { + return codebuddyResumeStateFromParseState(createCodebuddySessionParseState(file, messages)) +} + +export async function parseCodebuddySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +): Promise { + const lines = createInterface({ + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), + crlfDelay: Infinity + }) + return parseCodebuddySessionLines({ file, lines, platform, messages }) +} + +export async function parseCodebuddySessionContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + signal?: AbortSignal +): Promise { + return parseCodebuddySessionLines({ + file, + lines: remoteSessionContentLines(content, signal), + platform, + options + }) +} + +async function parseCodebuddySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions + messages?: TranscriptMessageSink +}): Promise { + const state = createCodebuddySessionParseState(args.file, args.messages) + for await (const line of args.lines) { + consumeCodebuddySessionLine(state, line) + } + return finalizeCodebuddySessionParseState(state, args.platform, args.options) +} diff --git a/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts b/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts new file mode 100644 index 00000000000..9086ab1e811 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + resetProjectDirCwdCacheForTests() +}) + +function codebuddySessionDirName(cwd: string): string { + return cwd + .replace(/[/\\:]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +async function writeCodeBuddySession(args: { + sessionsDir: string + cwd: string + id: string + timestamp: string +}): Promise { + const dir = join(args.sessionsDir, codebuddySessionDirName(args.cwd)) + await mkdir(dir, { recursive: true }) + const file = join(dir, `${args.id}.jsonl`) + await writeFile( + file, + jsonLines([ + { + type: 'message', + timestamp: args.timestamp, + sessionId: args.id, + cwd: args.cwd, + role: 'user', + content: [{ type: 'input_text', text: `prompt ${args.id}` }] + } + ]) + ) + const time = new Date(args.timestamp) + await utimes(file, time, time) +} + +describe('scanAiVaultSessions — CodeBuddy scope discovery', () => { + it('lists an older in-scope CodeBuddy session the recency cap would drop', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspace = '/home/ada/orca/workspaces/orca/feature' + + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: `${workspace}/packages/app`, + id: 'old-in-scope', + timestamp: '2026-05-01T10:00:00.000Z' + }) + // Newer, out of scope, and a sibling whose encoding shares the workspace prefix. + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/other', + id: 'recent-elsewhere', + timestamp: '2026-06-01T10:00:00.000Z' + }) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: `${workspace}-sibling`, + id: 'sibling', + timestamp: '2026-04-01T10:00:00.000Z' + }) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: [workspace] + }) + const ids = result.sessions.map((session) => session.sessionId) + + expect(ids).toContain('old-in-scope') + expect(ids).toContain('recent-elsewhere') + expect(ids).not.toContain('sibling') + expect(result.sessions.find((session) => session.sessionId === 'old-in-scope')?.agent).toBe( + 'codebuddy' + ) + }) + + it('finds an older session when another cwd shares its encoded bucket', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codebuddy-collision-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + for (const [cwd, id, timestamp] of [ + ['/home/ada/repo/app', 'in-scope', '2026-04-01T10:00:00.000Z'], + ['/home/ada/repo-app', 'collision', '2026-05-01T10:00:00.000Z'], + ['/home/ada/elsewhere', 'recent', '2026-06-01T10:00:00.000Z'] + ]) { + await writeCodeBuddySession({ sessionsDir: roots.codebuddyProjectsDir, cwd, id, timestamp }) + } + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: ['/home/ada/repo/app'] + }) + expect(result.sessions.map((session) => session.sessionId)).toContain('in-scope') + expect(result.sessions.map((session) => session.sessionId)).not.toContain('collision') + }) + + it('adds nothing when no scope is requested', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-noscope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/old', + id: 'old', + timestamp: '2026-05-01T10:00:00.000Z' + }) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/new', + id: 'new', + timestamp: '2026-06-01T10:00:00.000Z' + }) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1 }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual(['new']) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codebuddy-source.test.ts b/src/main/ai-vault/session-scanner-codebuddy-source.test.ts new file mode 100644 index 00000000000..a171ac6d671 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-source.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions — CodeBuddy transcripts', () => { + it('discovers ~/.codebuddy/projects transcripts and attributes them to codebuddy', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) + + await writeFile( + join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl'), + jsonLines([ + { + id: 'cb-msg-1', + timestamp: 1_777_634_400_000, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'fix the flaky test' }], + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-msg-2', + timestamp: 1_777_634_405_000, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'On it' }], + providerData: { model: 'glm-5.3-flash' }, + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-title-1', + timestamp: 1_777_634_406_000, + type: 'ai-title', + aiTitle: 'Fixing the flaky test', + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-summary-1', + timestamp: 1_777_634_407_000, + type: 'summary', + summary: 'Fix the flaky test', + providerData: { source: 'initial-user-message' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + const codebuddy = result.sessions.find((session) => session.agent === 'codebuddy') + expect(codebuddy).toMatchObject({ + sessionId: 'codebuddy-session', + cwd: '/repo/app', + title: 'Fix the flaky test', + model: 'glm-5.3-flash', + resumeCommand: "cd '/repo/app' && codebuddy --resume 'codebuddy-session'", + filePath: join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl') + }) + expect(codebuddy?.messageCount).toBe(2) + expect(codebuddy?.previewMessages.map((message) => message.text)).toEqual([ + 'fix the flaky test', + 'On it' + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts index ed2cf8d2eee..be080271269 100644 --- a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts +++ b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts @@ -32,6 +32,23 @@ export async function writeLogAgentFixtures( ]) ) + // CodeBuddy writes its own message-record shape under its own root. + await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) + await writeFile( + join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl'), + jsonlBody([ + { + id: 'codebuddy-msg-1', + timestamp: 1_777_634_430_000, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'CodeBuddy title' }], + sessionId: 'codebuddy-session', + cwd: '/tmp/codebuddy' + } + ]) + ) + await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true }) await writeFile( join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'), diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 4610c198cb3..a00f4edd2cf 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -5,6 +5,7 @@ import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers' import { createClaudeSessionResumeState } from './session-scanner-primary-parsers' +import { createCodebuddySessionResumeState } from './session-scanner-codebuddy-parser' import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers' import { createCopilotSessionResumeState } from './session-scanner-copilot-parser' import { createCursorSessionResumeState } from './session-scanner-cursor-parser' @@ -51,6 +52,8 @@ function resumableStateFactoryFor( switch (candidate.agent) { case 'claude': return (messages) => createClaudeSessionResumeState(candidate.file, messages) + case 'codebuddy': + return (messages) => createCodebuddySessionResumeState(candidate.file, messages) case 'codex': return (messages) => createCodexSessionResumeState(candidate.file, candidate.codexHome, messages) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index d9ef112f772..52795830e66 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -20,7 +20,7 @@ const REPRESENTATIVE_CWD_LINE_LIMIT = 200 const REPRESENTATIVE_FILE_LIMIT = 3 const TRANSCRIPT_EXTENSIONS = new Set(['.jsonl']) -// Both encodings are lossy; retain Claude’s existing cache and verify each Pi transcript. +// Bucket encodings are lossy; retain Claude’s cache and verify other transcripts individually. const PROJECT_DIR_CWD_CACHE_MAX = 2048 const projectDirCwdCache = new Map() @@ -75,7 +75,7 @@ type InScopeDiscoveryArgs = { } /** - * Fully include the transcripts of cwd-bucket directories (Claude, Pi) whose cwd + * Fully include the transcripts of cwd-bucket directories (Claude, Pi, CodeBuddy) whose cwd * falls inside the active workspace/project paths. * * Why: these agents keep one directory per cwd, e.g. `~/.claude/projects//`. @@ -108,7 +108,7 @@ export async function discoverInScopeCwdBucketFiles( } await collectBucketFiles({ agent: layout.agent, - scopePaths: layout.agent === 'pi' ? args.scopePaths : undefined, + scopePaths: layout.agent !== 'claude' ? args.scopePaths : undefined, projectDir, issues: args.issues, collected, diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 43d03d340dd..0d9b8cef5ab 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -54,6 +54,7 @@ export async function writeOpenCode2SqliteFixture(root: string): Promise export function isolatedScanRoots(root: string) { return { claudeProjectsDir: join(root, 'claude-projects'), + codebuddyProjectsDir: join(root, 'codebuddy-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), antigravityBrainDir: join(root, 'antigravity-brain'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 099b3593431..be97a4b3187 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,7 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string + codebuddyProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] // Why: tests inject a sandbox "real ~/.codex" so real-home attribution diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 2f5629f94c3..f56f17af796 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -27,7 +27,11 @@ import { getSessionParseCacheEntry } from './session-parse-cache-store' import { describeSkippedTranscriptRecords } from './session-transcript-record-budget' import { canStopParsingSessions } from './session-scan-cutoff' import { discoverInScopeCwdBucketFiles } from './session-scanner-scope-discovery' -import { CLAUDE_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' +import { + CLAUDE_CWD_BUCKET_LAYOUT, + PI_CWD_BUCKET_LAYOUT, + CODEBUDDY_CWD_BUCKET_LAYOUT +} from './session-cwd-bucket-layouts' import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta' import type { @@ -166,7 +170,11 @@ function mergeSessions( // Agents whose on-disk layout names a directory per cwd, so a scope's older // sessions can be found without reading every transcript's header. -const CWD_BUCKET_LAYOUTS = [CLAUDE_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT] +const CWD_BUCKET_LAYOUTS = [ + CLAUDE_CWD_BUCKET_LAYOUT, + PI_CWD_BUCKET_LAYOUT, + CODEBUDDY_CWD_BUCKET_LAYOUT +] async function scanInScopeSessions(args: { discoveries: SessionFileDiscovery[] diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 6ce4ea4869c..381e4d26b6f 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -19,8 +19,8 @@ import { isGitBashAvailable } from '../git-bash' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' | '.qoder' - scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' | '.codebuddy' + scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' | 'codebuddy-hook' usesWindowsCompatLauncher: boolean windowsHookShell?: 'powershell' } diff --git a/src/main/codebuddy/hook-service.test.ts b/src/main/codebuddy/hook-service.test.ts new file mode 100644 index 00000000000..e20ae46e39c --- /dev/null +++ b/src/main/codebuddy/hook-service.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => sandbox.home +})) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { codebuddyHookService, CODEBUDDY_HOOK_EVENTS } from './hook-service' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-codebuddy-test-')) + mkdirSync(join(sandbox.home, '.codebuddy')) +}) +afterAll(() => rmSync(sandbox.home, { recursive: true, force: true })) + +describe('CodeBuddy managed hooks', () => { + it('preserves user configuration through repeated install and removal', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + const settings = { + model: 'custom', + hooks: { Stop: [userHook] }, + statusLine: { command: 'user-status' } + } + writeFileSync(path, JSON.stringify(settings)) + expect(codebuddyHookService.install().state).toBe('installed') + expect(codebuddyHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of CODEBUDDY_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.statusLine).toEqual(settings.statusLine) + expect(codebuddyHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject(settings) + }) + + it('leaves malformed user settings untouched', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + writeFileSync(path, '{broken') + expect(codebuddyHookService.install().state).toBe('error') + expect(readFileSync(path, 'utf8')).toBe('{broken') + }) +}) diff --git a/src/main/codebuddy/hook-service.ts b/src/main/codebuddy/hook-service.ts new file mode 100644 index 00000000000..1a7c9619a34 --- /dev/null +++ b/src/main/codebuddy/hook-service.ts @@ -0,0 +1,34 @@ +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' +import { ClaudeHookService } from '../claude/hook-service' + +export const CODEBUDDY_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification' +] as const + +export const CODEBUDDY_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: CODEBUDDY_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const codebuddyHookService = new ClaudeHookService({ + agent: 'codebuddy', + source: 'codebuddy', + displayName: 'CodeBuddy', + settings: { + configDirName: '.codebuddy', + scriptBaseName: 'codebuddy-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: CODEBUDDY_MANAGED_HOOK_PLAN +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index caed5202f08..cbbba795d59 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -616,7 +616,8 @@ "zcode_label": "ZCode", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", - "opencode2_label": "OpenCode 2" + "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 05d26267342..3e2132d6011 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -385,7 +385,8 @@ "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", "muse_label": "Muse", - "zcode_label": "ZCode" + "zcode_label": "ZCode", + "codebuddy_label": "CodeBuddy" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 4f591ca8bf2..53ceaf15c39 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -595,6 +595,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e4a5cd42ee..1b55d87552a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 75f96c732ab..a5aca8c17b6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5d3eadd54d0..47fefd2f0e3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/lib/agent-catalog-platform.ts b/src/renderer/src/lib/agent-catalog-platform.ts new file mode 100644 index 00000000000..9a9eac351f0 --- /dev/null +++ b/src/renderer/src/lib/agent-catalog-platform.ts @@ -0,0 +1,13 @@ +export function getCatalogPlatform(): NodeJS.Platform { + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent + if (userAgent.includes('Windows')) { + return 'win32' + } + if (userAgent.includes('Mac')) { + return 'darwin' + } + if (userAgent) { + return 'linux' + } + return typeof process === 'undefined' ? 'linux' : process.platform +} diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 56cd2180c72..325d838552d 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -1,3 +1,4 @@ +import { getCatalogPlatform } from './agent-catalog-platform' import type React from 'react' import { ClaudeIcon, DroidIcon, OpenAIIcon } from '@/components/status-bar/icons' import openClaudeLogoUrl from '../../../../resources/openclaude-logo.png?url' @@ -30,20 +31,6 @@ export type AgentCatalogEntry = { homepageUrl: string } -function getCatalogPlatform(): NodeJS.Platform { - const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent - if (userAgent.includes('Windows')) { - return 'win32' - } - if (userAgent.includes('Mac')) { - return 'darwin' - } - if (userAgent) { - return 'linux' - } - return typeof process === 'undefined' ? 'linux' : process.platform -} - export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => [ { id: 'claude', @@ -349,6 +336,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => cmd: 'openclaw', faviconDomain: 'openclaw.ai', homepageUrl: 'https://github.com/openclaw/openclaw' + }, + { + id: 'codebuddy', + label: translate('auto.lib.agent.catalog.codebuddy_label', 'CodeBuddy'), + cmd: 'codebuddy', + faviconDomain: 'codebuddy.ai', + homepageUrl: 'https://www.codebuddy.ai/cli' } ]) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 41ccc2cc7d7..04ecd728541 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -100,6 +100,7 @@ export { formatAgentTypeLabel } from '../../../shared/agent-type-label' const ICONABLE_AGENT_TYPES: Record = { claude: true, 'claude-agent-teams': true, + codebuddy: true, openclaude: true, codex: true, autohand: true, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 24393546a81..e5b6d8327ae 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, @@ -56,6 +57,7 @@ describe('agentResumeHostAuthorityCapability', () => { ) ).toEqual({ claude: undefined, + codebuddy: AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, codex: undefined, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index efec3fe38b1..a5cefe6a74c 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, @@ -20,6 +21,7 @@ import { const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { // These shipped inside agent-session.host-authority.v1's enum, so the generic probe covers them. claude: undefined, + codebuddy: AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, codex: undefined, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index d8cf0c542cb..dda22ff12fd 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -45,7 +45,7 @@ describe('remote agent-session launch routing', () => { expect(legacy).not.toHaveBeenCalled() }) - it.each(['kimi', 'muse'] as const)( + it.each(['kimi', 'muse', 'codebuddy'] as const)( 'falls back to legacy when an older host lacks the %s resume capability', async (agent) => { const hostAuthority = vi.fn().mockResolvedValue('structured') @@ -70,23 +70,29 @@ describe('remote agent-session launch routing', () => { } ) - it('uses host authority when the host supports Muse resume', async () => { - const hostAuthority = vi.fn().mockResolvedValue('host') - const legacy = vi.fn() - mocks.supportsCapability.mockResolvedValue(true) + it.each(['muse', 'codebuddy'] as const)( + 'uses host authority when the host supports %s resume', + async (agent) => { + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn() + mocks.supportsCapability.mockResolvedValue(true) - await expect( - runRemoteAgentSessionLaunch({ - environmentId: 'env-1', - hostAuthority, - hostAuthorityCapability: agentResumeHostAuthorityCapability('muse'), - legacy - }) - ).resolves.toBe('host') - expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.muse-resume.v1') - expect(hostAuthority).toHaveBeenCalledOnce() - expect(legacy).not.toHaveBeenCalled() - }) + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority, + hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), + legacy + }) + ).resolves.toBe('host') + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'env-1', + `agent-session.${agent}-resume.v1` + ) + expect(hostAuthority).toHaveBeenCalledOnce() + expect(legacy).not.toHaveBeenCalled() + } + ) it('preserves the exact legacy path when the capability is absent', async () => { const hostAuthority = vi.fn().mockResolvedValue('structured') diff --git a/src/shared/__fixtures__/codebuddy-question-hooks.jsonl b/src/shared/__fixtures__/codebuddy-question-hooks.jsonl new file mode 100644 index 00000000000..81a69a899b6 --- /dev/null +++ b/src/shared/__fixtures__/codebuddy-question-hooks.jsonl @@ -0,0 +1,6 @@ +{"hook_event_name": "UserPromptSubmit", "prompt": "Use AskUserQuestion to ask me to choose red or blue. After I answer, reply with my color and stop.", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "SessionStart", "source": "startup", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "Notification", "notification_type": "permission_prompt", "message": "needs your permission to use AskUserQuestion", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [{"header": "Color", "multiSelect": false, "options": [{"description": "Choose red", "label": "Red"}, {"description": "Choose blue", "label": "Blue"}], "question": "Which color do you choose?"}]}, "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "PostToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [{"header": "Color", "multiSelect": false, "options": [{"description": "Choose red", "label": "Red"}, {"description": "Choose blue", "label": "Blue"}], "question": "Which color do you choose?"}]}, "tool_response": " \u00b7 Which color do you choose? \u2192 Red", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "Stop", "last_assistant_message": "Red", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} diff --git a/src/shared/__fixtures__/codebuddy-question-hooks.meta.json b/src/shared/__fixtures__/codebuddy-question-hooks.meta.json new file mode 100644 index 00000000000..28ac3a2d76b --- /dev/null +++ b/src/shared/__fixtures__/codebuddy-question-hooks.meta.json @@ -0,0 +1,6 @@ +{ + "version": "2.159.0", + "platform": "darwin", + "model": "fast-model", + "description": "Live CLI: question notification precedes PreToolUse; PreToolUse follows the answer; SessionStart follows UserPromptSubmit. Host paths and session identifiers replaced." +} diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index f3f67535963..9f742a5355d 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,3 +1,4 @@ +import { isCodebuddyNonInteractiveCommand } from './codebuddy-headless-command' import { isQoderHeadlessCommand } from './qoder-headless-command' import { isAnteHeadlessOneShotCommand } from './ante-headless-command' import { isDshNonInteractiveCommand } from './dsh-launch-command' @@ -18,6 +19,7 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial< > = { qoder: isQoderHeadlessCommand, claude: isPrintModeHeadlessOneShotCommand, + codebuddy: isCodebuddyNonInteractiveCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, ante: isAnteHeadlessOneShotCommand, diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 067e4552212..ab7e847c4ca 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -1,3 +1,4 @@ +import { normalizeCompatibleLifecycleEvent } from './providers/compatible-lifecycle-events' import { normalizeQoderEvent } from './providers/qoder-events' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' @@ -53,6 +54,16 @@ export function normalizeProviderEvent(input: { let payload: ParsedAgentStatusPayload | null switch (source) { + case 'codebuddy': + payload = normalizeCompatibleLifecycleEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayload + ) + break case 'claude': payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) break diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 40e59a1d820..80cd0fdc91c 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -30,6 +30,7 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo // Why: SessionStart lands an idle row (STA-3386) and must also drop stale // tool/prompt caches left by the pane's previous session. return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'codebuddy': case 'kimi': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' @@ -143,6 +144,7 @@ export function extractToolFields( case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through + case 'codebuddy': case 'kimi': // Muse uses Claude-compatible tool fields. // falls through diff --git a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts new file mode 100644 index 00000000000..2678dfc5e1e --- /dev/null +++ b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts @@ -0,0 +1,104 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +function readCompatibleState( + eventName: unknown, + payload: Record +): 'working' | 'waiting' | 'done' | null { + switch (eventName) { + case 'SessionStart': + // Compaction restarts the session during an active turn. + return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + return 'working' + case 'PreToolUse': + return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working' + case 'PermissionRequest': + return 'waiting' + case 'Stop': + case 'StopFailure': + case 'SessionEnd': + return 'done' + case 'PostCompact': + return payload.trigger === 'manual' ? 'done' : null + case 'Notification': + if ( + payload.notification_type === 'permission_prompt' || + payload.notification_type === 'elicitation_dialog' + ) { + return 'waiting' + } + return payload.notification_type === 'idle_prompt' ? 'done' : null + default: + return null + } +} + +export function normalizeCompatibleLifecycleEvent( + source: 'qoder' | 'codebuddy', + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + // CodeBuddy emits SessionStart after UserPromptSubmit, so it cannot settle a turn. + if (source === 'codebuddy' && eventName === 'SessionStart') { + return null + } + const stateName = + source === 'codebuddy' && eventName === 'PreToolUse' + ? 'working' + : readCompatibleState(eventName, hookPayload) + if (!stateName) { + return null + } + const resetOnNewTurn = isNewTurnEvent(source, eventName) + const toolFields = extractToolFields(source, eventName, hookPayload) + if ( + source === 'codebuddy' && + eventName === 'Notification' && + hookPayload.notification_type === 'permission_prompt' && + hookPayload.message === 'needs your permission to use AskUserQuestion' + ) { + toolFields.toolName = 'AskUserQuestion' + } + const snapshot = resolveToolState(state, paneKey, toolFields, { resetOnNewTurn }) + return normalizeAgentStatusPayload({ + state: stateName, + agentType: source, + prompt: resolvePrompt( + state, + paneKey, + source === 'codebuddy' && eventName === 'Notification' ? '' : promptText, + { resetOnNewTurn } + ), + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined, + interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined, + mainAgent: + eventName === 'StopFailure' + ? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } + : undefined + }) +} diff --git a/src/shared/agent-hook-listener/providers/qoder-events.ts b/src/shared/agent-hook-listener/providers/qoder-events.ts index bdf2a622dbe..c83923d58f7 100644 --- a/src/shared/agent-hook-listener/providers/qoder-events.ts +++ b/src/shared/agent-hook-listener/providers/qoder-events.ts @@ -1,51 +1,5 @@ -import { isAskUserQuestionTool } from '../../agent-question-answered-intent' -import { - normalizeAgentStatusPayload, - type ParsedAgentStatusPayload -} from '../../agent-status-types' +import { normalizeCompatibleLifecycleEvent } from './compatible-lifecycle-events' import type { HookListenerState } from '../listener-state' -import { - resolvePrompt, - resolveToolState, - shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' - -function readQoderState( - eventName: unknown, - payload: Record -): 'working' | 'waiting' | 'done' | null { - switch (eventName) { - case 'SessionStart': - // Compaction restarts the session during an active turn. - return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null - case 'UserPromptSubmit': - case 'PostToolUse': - case 'PostToolUseFailure': - return 'working' - case 'PreToolUse': - return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working' - case 'PermissionRequest': - return 'waiting' - case 'Stop': - case 'StopFailure': - case 'SessionEnd': - return 'done' - case 'PostCompact': - return payload.trigger === 'manual' ? 'done' : null - case 'Notification': - if ( - payload.notification_type === 'permission_prompt' || - payload.notification_type === 'elicitation_dialog' - ) { - return 'waiting' - } - return payload.notification_type === 'idle_prompt' ? 'done' : null - default: - return null - } -} export function normalizeQoderEvent( state: HookListenerState, @@ -53,35 +7,13 @@ export function normalizeQoderEvent( promptText: string, paneKey: string, hookPayload: Record -): ParsedAgentStatusPayload | null { - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - const stateName = readQoderState(eventName, hookPayload) - if (!stateName) { - return null - } - const resetOnNewTurn = isNewTurnEvent('qoder', eventName) - const snapshot = resolveToolState( +) { + return normalizeCompatibleLifecycleEvent( + 'qoder', state, + eventName, + promptText, paneKey, - extractToolFields('qoder', eventName, hookPayload), - { resetOnNewTurn } + hookPayload ) - return normalizeAgentStatusPayload({ - state: stateName, - agentType: 'qoder', - prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }), - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined, - interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined, - mainAgent: - eventName === 'StopFailure' - ? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } - : undefined - }) } diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index 2b7f0147e60..2940685665c 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -6,6 +6,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/claude': 'claude', '/hook/codex': 'codex', '/hook/qoder': 'qoder', + '/hook/codebuddy': 'codebuddy', '/hook/gemini': 'gemini', '/hook/antigravity': 'antigravity', '/hook/amp': 'amp', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 7e0d452a66b..bb2dca99f72 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -38,6 +38,7 @@ const AGENT_HOOK_SOURCES = [ 'claude', 'codex', 'qoder', + 'codebuddy', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index cc83527fc09..0bc5b67bc28 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -8,6 +8,7 @@ export const AGENT_HOOK_TARGETS = [ 'openclaude', 'codex', 'qoder', + 'codebuddy', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-icons/codebuddy.png b/src/shared/agent-icons/codebuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..ecaab1216f4b1edbee6aa61de58fb83dbfdc888e GIT binary patch literal 3794 zcmV;@4lVJCP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91D4+uX1ONa40RR91C;$Ke0D9(TtN;KGJV``BR9FcuSZR!1=UINg?QD0Q zJDX>`dmPtJ?0AdgI+aP^M3F0EZ;C^9O1A;-2YGb z$TSJ&-)l+u(oo)^Fz|2YXX(K_-MdHEX6V7f3bAZ#eE6|#YGQ)M#%aw2k)YC&iijjx zMqFs3X`-s5HOxkMn#1EIF0QiA1N%6G!#Ha#=fZ!mlyxbJ=-Nd({*x=exw$mlnp|2O za%6&#mAX%?P>L62#+a!$;@P`12M;~*doQkOjt~z>g_M^RMzcg%NRym&NRNp5)%2Ou zZjkHX8ZJBjD9fzlLr(vi{^OO8Zd`vbI=mszTo@+3&X{wKF~)sWut?lrGOoM5hs~A> z;$nIA=J=#~=?}m8%r8toeQ4W@FEw8~e)Zk=PW{zi z|JlaPR7Gs0<1O1m&%v>Uba1OmorDA#R zoRzrff9!AZFHGjOrkP*3{OnhEJ^w;bXvzx=L3--cTfhB{Z|!@Ef~qe*vF;#NVvUqU zgP(PKg14Ngo@AIy|H#< z?Xm4aYs3$zR8()q_a8iQ;`sU5JDZ>XW}`kr0&Pb)k{ok~4mn?RANAfWeRSsGFW#Om z(qU5$6?ssWz5_T*jqsfDP{@MI+?RI{mHE-d6Z5f&VBgalAKO7C(fne$(xH)YI`*}# z&Bo}>8}o0RxSaRN8AVj_B|`1dE6fU_Vt{X7r?*dD>bDztKN%F0@LVDN@`djvHzgKo zFi^&n00y355T0_!x|Xd>Z42Wn<(k%QR7s8QFJ!>gANsANW zBAFhO9VXj&<*2Z5oRgAuMzJ5uK~!S7C4}ki+FctNr65FY&=KHmaB@FWwI&@odbm~{ zd-dOc6{*2;J%pX=b?NK}^x+4$x~rkp4QnOZ2EKBN~IjK2JpFBC=g-n;LgJ4Z$CV@uLhi|OQvGb61{ zWv|Aa2RSlxrev&S0z-&70))_>0F)tB1Zd^Dhxrn!sMc+BnC(5VzfvQPY&<@g5O^V@ z{W@eoBBW-EzI1fo@Mz_=pT4?t?~5q4>@L&gi!^g>G33=K2+PdjCRBM)E59;7K?Z>h z*%C!8%}Qz0AU3szh^yuLP4yi+Tg_oWnJ^Q+<+xXn6vP|}?(v3;LnCzbxyfgaPQLQW z@k^IycRsm}*|z(^%NG{sb=FIm9d;(>EMOc^Owm`y3sh8VFt`#tks@^bUacMlQL%IP zx}Cej$@K)ztTjAipiYlqji`7JAb=+H0O(=p%L_h8Q(9hL9UH4lUDDiastYBB(k9~q zg8^2>bIN))Hz=P}K^R7*TipHBzN23WN7qm%B_5er<(d^D#I50g&N#wj9XMJo z>bEJWqIoz=DRjtPi`RS$u3x1_eXO&ZXZ-@P$rajI3FIO;sMs$7{nCPq5Md+vr{6!+ z81iica)Lx#wgj+2odQ*Xt_^fU_50<;iLqxwG{CX*{r(_19|*rF9@R8I;GqQ9brsBdkJk6VuS|+q>fF-7hm?O1o0%6%suN zZBh-RSPDN?eaG9CYPNP`@SWd3xc{j-D2^%39L5&N51f&40KK6krW;r3jT7aG*FRfX z$+O&+#tS|A3tHsdUbuAY@|Eu7hGv{l5Gz1S_RPZLdq+mcCxgg|fMIxV-&iU0VD>i; zJDpBmE={hj95}RPY=R_c1LZfdloC(@dtqc&m%w3k;-?SKpS_)SLqoA~Tm%8Q7&FPa zWQJ+00qeOt?bnY_|IUlsH$)W35t-)mhmVfJ6Cf&AR&c&?su>8XR-pM_x9UymbX=T( zY_xMa=5YaekEp~LTe>=}-}>1H%rzDkb7wHS@HavuTkbe~2ZE+Tfk=3grp4^7{>c-U z{_<}gH#(4M(Y6CdCDG-gD})lV!YRu9S^$4j=rXDnC>!UI;;Mu*3F*Oo&kt{$xNzyh zZ3bHdsSqtL5^DovjCN2kze;dtBwA_VbutKsd!2{V7neVLpEhhEUa2$|79cX10;JF< z-X*chRSts^CN79Fp)$}0M9NVFHU)&b8J+($|KX4R=gRfveqKq7N-qm!PbAzJDvX$bHo#tZ+JS_iLJaK$ zL-QKoly*o7x^kP|e(Tq_Zq5Z^ja+DD=*mc{O0IcX7S3kFtz>8<7#(e{c8YuR3%Z>N z%b+jXB3x3I{p;6e7Z*vUJ-Tyu_L(EwfhVTGjU`wfOsOO$10({f0$8XhXlaSweDn6l zm*?pC$P9y`4KRZ6KFC~E}(+z zjCxvSag$BY+^i2YBTA?~WS=;&b$D$;QI6AyUP&DlTlf=RAENb}lbIWrKKtxpmJV=Y zy^VqgwSr)sUO##I&ircbWFg4LFeZR{Y8Y%u-$FElf=9Z5az`aay5!l#ne&&fQKuxd zBCPNuFHH1Yn<@pxeJ$D|N%tI>f>`lX)2`iI+*kczO7e{Rfkg(dzxX_uu)&&6OpJV_-sSM$3J=GBexm!w*!fib_%DDy_)w zSl#W}F|lcFrPYMywwdV5NV~x1XjByRxR{=M@ZR~w1r1L`i#e^?vS!t^U6h9PV*Boi z4O0|E9$#ganPbmA+wG*EeR>_!gc%lr1hOBl*xR$qrK^x=W=^5hInP^>e(86g`QCRo ze)l_%@7yt2tpF=I3k-`GBpZ+y)rM+K{^wi&cdtXYx^#b;KD~TNCBn(9V5?vL`qOr80evQ5?#LG> zW@fH2$#S?|G$z1s2M%m%SP)eH?Y};5Hsj@X+Ft5r8YU^4nw)rYzo=qMF%akyp1#cK z+uu60vfOJn237$^OH;2~)*GWh7wQPA(Lgn%JZ<9xhOZLQruDR8{hBxi%X^0d;g(Tc zfM())r=W$E{^gmu#if;gnugV=H9EBK=^ZGiR~iiN4_^K|jFD6(s`S2icr-7H5IT!O zI4X4bt8?#_vO~}XY)d(6KDNAcH5%PsZHgFGVYF>(_=68mm!fBzuHSIW)p8*z7As|c zn0M>0SLcoVZnahp)qBT|Ozqu{JgDN680>(s1Z+E_C`ytfiUJPsW2gn32@N-!)|5tP z&Vmk0$!xVbboS#<&_hbiVHnIup{wd(e5k4B>-x!qyLUAr-yrXed@$%1JPiH~Vk2=0b3xi@ zgAv2U1N%1%n<85(}~+$R@r>wDU7(LTL#Rn=pjh#(P_gjE6;Qy#i> zNZQemAKAa>(5@%OYdjf5{X7r{ulp?>XVc>YQAkczLG3<(UOM;yK)4!a0u+-13D&Qy^v9*PT7FLAerN&=sU|9u6 zR1AvmXcD!eU@M|-TvrSE_D9+Hhx?}^NoMh;iD}UYlXa56uZwi%!Bqw0LjE)}M z{qz6*pp&M$pN7$}b76_12&)CN_-jGHRm77J-mhqcd}_S4XUnF^dNK-e!{1#7xuCCj zD16H21`r=J>-XCpJuvRJ#GVN{3@Pv%Z|^9uw<7R)AH-s4`pWW&>4#~a_RAEbR4Qnr zO6t&VJ+*n=&Z$k?0^ds%wuYq-SR%qB9_{8Z<%124M?A6r2XSUv_ const TUI_AGENT_KIND_BY_AGENT = { claude: 'claude-code', 'claude-agent-teams': 'claude-agent-teams', + codebuddy: 'codebuddy', openclaude: 'openclaude', codex: 'codex', autohand: 'autohand', diff --git a/src/shared/agent-node-package-entrypoints.ts b/src/shared/agent-node-package-entrypoints.ts index 905d41f95f0..7f8f9abd7cb 100644 --- a/src/shared/agent-node-package-entrypoints.ts +++ b/src/shared/agent-node-package-entrypoints.ts @@ -6,6 +6,7 @@ // new agent's install path is a data edit, as in agent-node-entrypoint-identities.ts. export const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record = { codex: ['node_modules/@openai/codex/'], + codebuddy: ['node_modules/@tencent-ai/codebuddy-code/'], gemini: ['node_modules/@google/gemini-cli/'], // Why: ZCode's npm bin is `dist/zcode.cjs`, so a package install runs as `node …zcode.cjs` // and never shows `zcode` as the foreground name (a SEA build still matches by name). diff --git a/src/shared/agent-session-option-catalog-codebuddy.ts b/src/shared/agent-session-option-catalog-codebuddy.ts new file mode 100644 index 00000000000..d0a8afa04c5 --- /dev/null +++ b/src/shared/agent-session-option-catalog-codebuddy.ts @@ -0,0 +1,44 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +const CODEBUDDY_EFFORT: CatalogOption = { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' } + ], + defaultValue: 'medium' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']) + } +} + +export const CODEBUDDY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + // Stable CLI aliases let CodeBuddy resolve the account's current model choices. + models: [ + { id: 'default-model', label: 'Default' }, + { id: 'fast-model', label: 'Fast' }, + { id: 'balanced-model', label: 'Balanced' }, + { id: 'primary-model', label: 'Primary' }, + { id: 'deep-model', label: 'Deep' } + ].map((model) => ({ ...model, options: [CODEBUDDY_EFFORT] })), + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) + }, + unknownModelOptions: [CODEBUDDY_EFFORT] +} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index a8e26f5a0ed..74a7f384801 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -1,3 +1,4 @@ +import { CODEBUDDY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codebuddy' import type { AgentType } from './agent-status-types' import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity' import { @@ -34,6 +35,7 @@ export { createClaudeCatalogOptions } const CATALOGS: AgentSessionOptionCatalogMap = { antigravity: ANTIGRAVITY_SESSION_OPTION_CATALOG, claude: CLAUDE_SESSION_OPTION_CATALOG, + codebuddy: CODEBUDDY_SESSION_OPTION_CATALOG, codex: CODEX_SESSION_OPTION_CATALOG, gemini: GEMINI_SESSION_OPTION_CATALOG, cursor: CURSOR_SESSION_OPTION_CATALOG, diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 577492ddefc..397a1a9451f 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -28,6 +28,10 @@ describe('agent session resume metadata', () => { expect(isResumableTuiAgent('kimi')).toBe(true) }) + it('treats CodeBuddy as a resumable TUI agent', () => { + expect(isResumableTuiAgent('codebuddy')).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' }], @@ -70,6 +74,7 @@ describe('agent session resume metadata', () => { it.each([ ['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']], + ['codebuddy', { key: 'session_id', id: 's1' }, ['codebuddy', '--resume', 's1']], ['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']], ['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']], ['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']], diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 4cbc240c764..40a3699af3c 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -5,6 +5,7 @@ import type { TuiAgent } from './tui-agent' export const RESUMABLE_TUI_AGENTS = [ 'claude', + 'codebuddy', 'codex', 'qoder', 'gemini', @@ -196,6 +197,7 @@ export function extractAgentProviderSession( // since recent Claude Code names the transcript file with a UUID that differs // from the hook session_id (so the id-based glob no longer finds it). case 'qoder': + case 'codebuddy': case 'claude': case 'codex': { const id = readSessionId(payload, ['session_id']) @@ -277,6 +279,8 @@ export function getAgentResumeArgv( ): string[] | null { const id = providerSession.id switch (agent) { + case 'codebuddy': + return providerSession.key === 'session_id' ? ['codebuddy', '--resume', id] : null case 'claude': return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null case 'codex': diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index a4d70a76037..72d1b1b6d63 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -4,6 +4,7 @@ import type { AgentType } from './agent-status-types' // (e.g. native chat's empty state on both surfaces) from one source of truth. const WELL_KNOWN_LABELS: Record = { claude: 'Claude', + codebuddy: 'CodeBuddy', openclaude: 'OpenClaude', codex: 'Codex', qoder: 'Qoder CLI', diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index da8ced39e0e..db89cb41263 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -28,6 +28,17 @@ describe('buildAiVaultResumeCommand', () => { ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') }) + it('resumes CodeBuddy sessions with the Claude-compatible --resume flag', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codebuddy', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && codebuddy --resume 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a direct queued command for a live cmd shell', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index cfff419c95c..efb2ddbe5d3 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -218,6 +218,7 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` + case 'codebuddy': case 'claude': case 'zcode': case 'cursor': diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index d3c2211ab84..4875ab6e889 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -40,6 +40,7 @@ export const AI_VAULT_DELETABLE_AGENTS = [ 'pi', 'omp', 'claude', + 'codebuddy', 'rovo', 'grok', 'cline' diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 28f93dcfbe1..e337f98d7a0 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', + 'codebuddy', 'codex', 'hermes', 'pi', @@ -51,6 +52,7 @@ export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', + codebuddy: 'CodeBuddy', codex: 'Codex', hermes: 'Hermes', pi: 'Pi', diff --git a/src/shared/codebuddy-agent.test.ts b/src/shared/codebuddy-agent.test.ts new file mode 100644 index 00000000000..6915cf8d573 --- /dev/null +++ b/src/shared/codebuddy-agent.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { getAgentResumeArgv } from './agent-session-resume' + +describe('CodeBuddy harness', () => { + it('replays the live question lifecycle without settling at late SessionStart', () => { + const state = createHookListenerState() + const events = readFileSync( + join(__dirname, '__fixtures__/codebuddy-question-hooks.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + const rows = events.map((event) => normalizeAndAccept(state, 'codebuddy', event)) + expect(rows.map((row) => row?.payload.state ?? null)).toEqual([ + 'working', + null, + 'waiting', + 'working', + 'working', + 'done' + ]) + expect(rows[2]?.payload).toMatchObject({ agentType: 'codebuddy', toolName: 'AskUserQuestion' }) + expect(rows[5]?.payload).toMatchObject({ agentType: 'codebuddy', lastAssistantMessage: 'Red' }) + expect(rows[5]?.providerSession?.id).toBe('codebuddy-verification') + }) + + it.each([ + 'codebuddy', + 'cbc', + 'C:\\Tools\\codebuddy.cmd', + 'node /usr/lib/node_modules/@tencent-ai/codebuddy-code/dist/codebuddy.js' + ])('recognizes interactive %s', (command) => { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('codebuddy') + }) + + it('excludes print runs and resumes the original provider session', () => { + for (const flag of ['--print', '--serve', '--acp', '--bg', '--background']) { + expect(recognizeAgentProcessFromCommandLine(`codebuddy ${flag}`)).toBeNull() + } + expect(recognizeAgentProcessFromCommandLine('codebuddy -- --serve')?.agent).toBe('codebuddy') + expect(getAgentResumeArgv('codebuddy', { key: 'session_id', id: 'session-1' })).toEqual([ + 'codebuddy', + '--resume', + 'session-1' + ]) + }) + + it('passes explicit model and effort preferences through the CLI flags', () => { + const catalog = getAgentSessionOptionCatalog('codebuddy') + expect(catalog?.modelApply.launchArgs?.('fast-model')).toEqual(['--model', 'fast-model']) + expect(catalog?.unknownModelOptions?.[0].apply.launchArgs?.('high')).toEqual([ + '--effort', + 'high' + ]) + expect(catalog?.modelApply.removeAgentArgs?.(['--model', 'old', '--', 'prompt'])).toEqual([ + '--', + 'prompt' + ]) + }) +}) diff --git a/src/shared/codebuddy-headless-command.ts b/src/shared/codebuddy-headless-command.ts new file mode 100644 index 00000000000..5c632b27b3b --- /dev/null +++ b/src/shared/codebuddy-headless-command.ts @@ -0,0 +1,13 @@ +import { agentArgOptionTokens } from './agent-session-option-agent-args' +import { isPrintModeHeadlessOneShotCommand, optionName } from './print-mode-headless-command' + +const NON_INTERACTIVE_FLAGS = new Set(['--serve', '--acp', '--bg', '--background']) + +export function isCodebuddyNonInteractiveCommand(tokens: readonly string[]): boolean { + return ( + isPrintModeHeadlessOneShotCommand(tokens) || + agentArgOptionTokens(tokens) + .slice(1) + .some((token) => NON_INTERACTIVE_FLAGS.has(optionName(token))) + ) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bd8b3f62a31..3a877a80900 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -236,6 +236,8 @@ export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = 'agent-session.opencode2-resume.v1' as const export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const +export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY = + 'agent-session.codebuddy-resume.v1' as const export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY = 'agent-session.qoder-resume.v1' as const export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY = @@ -402,6 +404,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index f8d1a913172..1955bfa6d58 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -14,6 +14,7 @@ import type { TuiAgent } from './tui-agent' export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { claude: 'claude-code', 'claude-agent-teams': 'claude-code', + codebuddy: 'codebuddy', // Why: Orca states OpenClaude reads Claude-owned roots (native-chat-agent-profiles). openclaude: 'claude-code', codex: 'codex', diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index cee76b677d6..89f62138407 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -10,6 +10,7 @@ import type { GlobalSettings } from './global-settings-types' export const AGENT_KIND_VALUES = [ 'claude-code', 'claude-agent-teams', + 'codebuddy', 'openclaude', 'codex', 'autohand', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 15318913968..27cda1d1979 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -97,6 +97,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', pasteNeedsTypedRequest: true }, + codebuddy: { + detectCmd: 'codebuddy', + detectCmdAliases: ['cbc'], + promptInjectionMode: 'argv' + }, openclaude: { detectCmd: 'openclaude', promptInjectionMode: 'argv', @@ -148,7 +153,6 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'opencode2', // The private server inherits this pane's hook endpoint and identity. launchCmd: 'opencode2 --standalone', - expectedProcess: 'opencode2', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', draftPasteReadyTimeoutMs: 20_000 diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index a90daa3242f..31b6035a953 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -8,6 +8,7 @@ import type { TuiAgent } from './tui-agent' export const TUI_AGENT_DISPLAY_NAMES: Record = { claude: 'Claude', 'claude-agent-teams': 'Claude Agent Teams', + codebuddy: 'CodeBuddy', openclaude: 'OpenClaude', codex: 'Codex', devin: 'Devin', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 3a9912bf066..9fc8eb0a691 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -5,6 +5,7 @@ export type AgentPermissionMode = 'yolo' | 'manual' | 'mixed' export const YOLO_TUI_AGENT_ARGS: Partial> = { claude: '--dangerously-skip-permissions', + codebuddy: '--dangerously-skip-permissions', 'claude-agent-teams': '--dangerously-skip-permissions', openclaude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', diff --git a/src/shared/tui-agent-rest-signal.test.ts b/src/shared/tui-agent-rest-signal.test.ts index f7d11032875..6e573acc0b0 100644 --- a/src/shared/tui-agent-rest-signal.test.ts +++ b/src/shared/tui-agent-rest-signal.test.ts @@ -29,6 +29,7 @@ const EXPECTED_REST_SIGNALS: Record = { grok: 'title', muse: 'ready-body', qoder: 'ready-body', + codebuddy: 'none', autohand: 'none', ante: 'none', trae: 'none', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 85567dcb334..acf305dad59 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -45,7 +45,8 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'rovo', 'hermes', 'devin', - 'openclaw' + 'openclaw', + 'codebuddy' ] as const satisfies readonly TuiAgent[] // Why: fresh installs should expose Claude Agent Teams in agent pickers; the diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 19722220888..a98c45101fd 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -3,6 +3,7 @@ export type TuiAgent = | 'claude' // Claude Code | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes + | 'codebuddy' // CodeBuddy Code | 'openclaude' // OpenClaude | 'codex' // OpenAI Codex | 'autohand' // Autohand Code CLI