diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 653f10e3fc6..f6dfcfcd55b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -6,6 +6,7 @@ "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/grok-replay-guard.ts", "../src/main/claude/hook-script.ts", + "../src/main/qoder/hook-service.ts", "../src/main/claude/claude-session-end-hook-capability.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", diff --git a/docs/reference/qoder-integration.md b/docs/reference/qoder-integration.md new file mode 100644 index 00000000000..3a3a90121cd --- /dev/null +++ b/docs/reference/qoder-integration.md @@ -0,0 +1,89 @@ +# Qoder CLI integration + +Orca registers `qodercli` as `qoder`: detection, picker/settings, desktop and mobile +identity, prompt launch, permission flags, managed hooks, workspace trust, and +session resume use the existing TUI-agent and hook-status paths. + +## Verified contract + +Verified on macOS with Qoder CLI 1.1.64, including its versioned executable +`qodercli-1.1.64`: + +- `--prompt-interactive` starts an interactive prompt; `--resume ` resumes a + session; `--dangerously-skip-permissions` is the permission bypass flag. +- Hooks use the Claude-shaped nested configuration in `~/.qoder/settings.json`. + Orca registers its own `/hook/qoder` source and preserves user hooks. +- Real SessionStart, UserPromptSubmit, Notification and SessionEnd events are + captured in `src/shared/__fixtures__/qoder-no-account-hooks.jsonl`, including + `source: resume` with the original session ID. +- Trust is `permissions.trustDirectories` in that settings file, using the + canonical workspace path. A workspace `.trusted` marker did not bypass the + trust dialog in this version. Remote trust uses the execution host filesystem. +- Qoder emits its `◇ … | Ready` title while the trust menu owns input. Readiness + therefore requires the live composer text, not merely this title or silence. + Raw PTY fixtures under `src/main/runtime/__fixtures__/qoder-*` cover trust, + unauthenticated prompt handling and the ready composer. +- A hidden Orca dev instance launched Qoder from New Tab in a folder workspace. + Its icon, label, terminal rendering and failed-turn indicator were inspected. + A harmless prompt reached Qoder, which reported its credit usage limit; the + canonical hook store recorded Qoder identity, session metadata and failure. + +## Compatibility and limits + +Windows hooks explicitly select Qoder's documented PowerShell shell, avoiding +an assumption that Qoder uses Git Bash just because it is installed. They reuse +the existing managed `.cmd` payload without adding an encoded PowerShell hop. +The configuration shape and preservation are tested; Windows, Linux and SSH +execution have not been exercised live here. + +Resume requests to remote hosts require `agent-session.qoder-resume.v1`, so an +older host is not sent an agent enum it cannot accept. Remote hook installation +and trust preservation have automated coverage. + +Successful model output, tool execution and live permission dialogs remain +unverified because the available account has exhausted its credits. Hook event +mapping for these paths follows the official documentation. The China executable +`qoderclicn` is not registered; it was not available for verification. + +New Tab follows the existing manual-launch trust behavior: an untrusted folder +can show Qoder's trust dialog. Automated workspace/draft launch paths run the +trust preflight before prompt delivery. + +## Sources + +- [Official hooks documentation](https://docs.qoder.com/cli/hooks) +- [#15291](https://github.com/stablyai/orca/pull/15291): registration and hook proposal +- [#13311](https://github.com/stablyai/orca/pull/13311): icon asset (commit + `b56197025530adb1b82d97a364a8c3d4a02c0d42`) and agent integration +- [#8611](https://github.com/stablyai/orca/pull/8611) and + [#12910](https://github.com/stablyai/orca/pull/12910): contributor implementations +- [#16540](https://github.com/stablyai/orca/issues/16540): Qoder misidentified as Gemini + +Contributor patches informed the integration; they were not applied wholesale. +In particular, the trust marker proposal was replaced using the installed CLI's +observed settings write, and no renderer workaround was added because the live +WebGL terminal rendered correctly. + +## Cross-review of open proposals + +Reviewed the current patches for all six open Qoder PRs on 2026-09-28: + +| PR | Incorporated or confirmed | Deliberate differences | +| --- | --- | --- | +| #7502 (vincent-lxc) | Agent registration, mobile parity, identity before Gemini | Use structural titles and the current public permission flag. | +| #8611 (Eridanus117, building on #7502) | Qoder hook source, notification/permission mapping, session resume | 1.1.64 supports `--prompt-interactive`; preserve startup session identity and ignore compact restarts. | +| #9655 (xingqingzzp-gif) | Cross-checked minimum registration coverage | Placeholder icon and detection-only scope are superseded by the fuller integration; no code copied. | +| #12910 (jyang2004) | Reuse the Claude-compatible installer with an event subset, plus remote installation | Use observed `.qoder` path and `/hook/qoder`; Qwen and the China build remain separate work. | +| #13311 (sorrycc) | Icon, structural title disambiguation, headless detection, display glyph cleanup | No DOM renderer override: 1.1.64 renders correctly in the live WebGL terminal. No unverified executable alias. | +| #15291 (adlternative) | Versioned binary recognition, trust preflight wiring, skills mapping and resume | Trust comes from settings, not `.trusted`; include notifications, permission and failure events with Qoder-specific normalization. | + +The rendered left sidebar was checked in the hidden Electron app using a temporary +folder workspace and real Qoder hooks. The workspace fixture needed a parent path +on its project group to appear in the sidebar. No agent status was injected into +the renderer. A real prompt updated the row text and returned a credit-limit error; +the row and workspace card showed failure with the Qoder icon. A DOM observer +recorded the prompt-row transition before failure. Permission/waiting and successful +completion remain automated event-mapping tests, not live model/tool verification. + +Commit co-authors credit all six proposal authors for the implementation and +registration groundwork, including xingqingzzp-gif’s minimal registration proposal. diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 5a80fedb675..8d0a6d474f7 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> zcode: 'zcode.z.ai', omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', + qoder: 'qoder.com', gemini: 'gemini.google.com', antigravity: 'antigravity.google', goose: 'goose-docs.ai', diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 61dc9596eff..31513658035 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 { qoderHookService } from '../qoder/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { HookInstallAgent } from '../../shared/telemetry-events' import { ampHookService } from '../amp/hook-service' @@ -44,6 +45,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['openclaude', () => openClaudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], + ['qoder', () => qoderHookService.install()], ['antigravity', () => antigravityHookService.install()], ['amp', () => ampHookService.install()], ['cursor', () => cursorHookService.install()], @@ -70,6 +72,7 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['openclaude', () => openClaudeHookService.refreshManagedScripts()], ['codex', () => codexHookService.refreshManagedScripts()], ['gemini', () => geminiHookService.refreshManagedScripts()], + ['qoder', () => qoderHookService.refreshManagedScripts()], ['antigravity', () => antigravityHookService.refreshManagedScripts()], ['cursor', () => cursorHookService.refreshManagedScripts()], ['droid', () => droidHookService.refreshManagedScripts()], @@ -88,6 +91,7 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['openclaude', () => openClaudeHookService.remove()], ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], + ['qoder', () => qoderHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['amp', () => ampHookService.remove()], ['cursor', () => cursorHookService.remove()], @@ -112,6 +116,7 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['openclaude', () => openClaudeHookService.getStatus()], ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], + ['qoder', () => qoderHookService.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 540d9deb6d2..b3166a6c90e 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -82,6 +82,20 @@ const buildersByAgent = new Map([ remote: (path) => [getClaudeRemoteCommand(path)] } ], + [ + 'qoder', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], [ 'codex', { @@ -206,10 +220,15 @@ describe('managed hook command contract', () => { expect(commands.length).toBeGreaterThan(0) for (const command of commands) { expect(command.length).toBeGreaterThan(0) - // Native Windows Codex evaluates PowerShell variables without Grok's dollar-byte scanner. + // Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner. const scannedCommand = - agent === 'codex' && platform === 'win32' && command.startsWith('if (Test-Path') - ? command.replaceAll('$LASTEXITCODE', '').replaceAll('$env:', '') + platform === 'win32' && + ((agent === 'codex' && command.startsWith('if (Test-Path')) || + (agent === 'qoder' && command.startsWith('$scriptPath = Join-Path'))) + ? command + .replaceAll('$LASTEXITCODE', '') + .replaceAll('$env:', '') + .replaceAll('$scriptPath', '') : command expect(findBareHookCommandVariables(scannedCommand), command).toEqual([]) } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 148d5f719d7..f3cc23937b4 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,3 +1,4 @@ +import { qoderHookService } from '../qoder/hook-service' import { describe, expect, it, vi } from 'vitest' import { parse as parseJsonc } from 'jsonc-parser' import type { SFTPWrapper } from 'ssh2' @@ -685,6 +686,18 @@ describe('remote hook service installers', () => { expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755) }) + it('installs Qoder on the execution host with its own event endpoint', async () => { + const { sftp, fs } = createFakeSftp() + const result = await qoderHookService.installRemote(sftp, '/home/dev/') + expect(result.state).toBe('installed') + expect(result.configPath).toBe('/home/dev/.qoder/settings.json') + const settings = JSON.parse(fs.files.get(result.configPath) ?? '{}') + expect(settings.hooks.SessionEnd).toHaveLength(1) + expect(settings.hooks.Notification).toHaveLength(1) + expect(settings.hooks.TeammateIdle).toBeUndefined() + expect(fs.files.get('/home/dev/.orca/agent-hooks/qoder-hook.sh')).toContain('/hook/qoder') + }) + it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { const { sftp } = createFakeSftp() const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', { 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 376e53be125..f9ff9c232e0 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 @@ -22,6 +22,7 @@ import { kimiHookService } from '../kimi/hook-service' import { dshHookService } from '../dsh/hook-service' import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { qoderHookService } from '../qoder/hook-service' import { zcodeHookService } from '../zcode/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' @@ -51,6 +52,7 @@ describe('remote hook service registry coverage', () => { ['devin', devinHookService], ['kimi', kimiHookService], ['muse', museHookService], + ['qoder', qoderHookService], ['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 f0346971068..272e330be11 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 { qoderHookService } from '../qoder/hook-service' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types' import { ampHookService } from '../amp/hook-service' @@ -62,6 +63,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ }) ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], + ['qoder', (sftp, remoteHome) => qoderHookService.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 9c0d030b04d..a405199a6e7 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 @@ -22,6 +22,7 @@ afterEach(() => vi.restoreAllMocks()) * classifier names no boundary for that source. That is not the same as "can never revive": * mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */ const NEW_TURN_EVENT: Record = { + qoder: 'SessionStart', claude: 'SessionStart', kimi: 'UserPromptSubmit', codex: 'SessionStart', diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 30e2ba487f3..d84fc68a468 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -6,7 +6,7 @@ import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index efbe71fc9e3..e02fe374701 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -1,6 +1,7 @@ /** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. * Split from hook-service.ts so the service owns install/status and this owns script text, * mirroring the same split under src/main/cursor/. */ +import type { AgentHookSource } from '../../shared/agent-hook-relay' import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { @@ -18,10 +19,12 @@ import { export function getManagedScript( target: 'local' | 'posix' = 'local', options: { + source?: AgentHookSource skipWhenDevinImportsClaude?: boolean skipWhenGrokImportsClaude?: boolean } = {} ): string { + const source = options.source ?? 'claude' if (target === 'local' && process.platform === 'win32') { return [ '@echo off', @@ -46,7 +49,7 @@ export function getManagedScript( ] : []), // Why: use curl.exe to avoid an extra PowerShell startup per hook. - buildWindowsAgentHookCurlPostCommand('claude'), + buildWindowsAgentHookCurlPostCommand(source), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -59,7 +62,7 @@ export function getManagedScript( 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), - ...buildPosixHookSpoolLines('claude'), + ...buildPosixHookSpoolLines(source), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -84,7 +87,7 @@ export function getManagedScript( ' exit 0', 'fi', // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. - ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + ...buildPosixAgentHookPostCommand(source).map((line, index, lines) => index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line ), 'exit 0', diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 73e7add40dc..1e9a0d91982 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,4 +1,5 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs' +import type { AgentHookSource } from '../../shared/agent-hook-relay' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { @@ -38,6 +39,7 @@ import { hasSameManagedHookInvocation, removeManagedHooks, removeManagedStatusLine, + type ApplyManagedClaudeHooksOptions, type ClaudeCompatibleHookSettings } from './hook-settings' @@ -45,6 +47,8 @@ type ClaudeHookServiceOptions = { agent: AgentHookInstallStatus['agent'] displayName: string settings: ClaudeCompatibleHookSettings + source?: AgentHookSource + events?: ApplyManagedClaudeHooksOptions['events'] } type ClaudeHookInstallOptions = { @@ -82,7 +86,7 @@ export class ClaudeHookService { const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 - for (const event of CLAUDE_EVENTS) { + for (const event of this.options.events ?? CLAUDE_EVENTS) { const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -115,6 +119,7 @@ export class ClaudeHookService { await refreshManagedScriptIfPresent( getManagedScriptPath(this.options.settings), getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) @@ -145,11 +150,12 @@ export class ClaudeHookService { config, hook, getManagedScriptFileName(this.options.settings), - this.options.agent === 'claude' ? options : undefined + { ...(this.options.agent === 'claude' ? options : {}), events: this.options.events } ) writeManagedScript( scriptPath, getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) @@ -211,12 +217,10 @@ export class ClaudeHookService { // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) - const nextConfig = applyManagedHooks( - config, - hook, - remoteScriptFileName, - this.options.agent === 'claude' ? options : undefined - ) + const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName, { + ...(this.options.agent === 'claude' ? options : {}), + events: this.options.events + }) // Why: write scripts before settings to avoid settings pointing to missing scripts. // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. @@ -224,6 +228,7 @@ export class ClaudeHookService { sftp, remoteScriptPath, getManagedScript('posix', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 42a986cad20..185aa0b545a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -19,9 +19,10 @@ import { isGitBashAvailable } from '../git-bash' import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' - scriptBaseName: 'claude-hook' | 'openclaude-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' + scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' usesWindowsCompatLauncher: boolean + windowsHookShell?: 'powershell' } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { @@ -109,6 +110,7 @@ const CLAUDE_SESSION_END_EVENT = { export type ApplyManagedClaudeHooksOptions = { claudeVersion?: string + events?: readonly { eventName: string; definition: Omit }[] } export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { @@ -171,6 +173,14 @@ export function getManagedLifecycleHook( if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) { return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } + if (settings.windowsHookShell === 'powershell') { + return { + type: 'command', + command: getWindowsPowerShellLifecycleCommand(scriptPath), + shell: 'powershell', + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } + } return getWindowsManagedLifecycleHook(scriptPath, options) } @@ -190,19 +200,21 @@ export function getWindowsManagedLifecycleHook( if (directCommand) { return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } + return { + type: 'command', + command: wrapWindowsPowerShellEncodedCommand(getWindowsPowerShellLifecycleCommand(scriptPath)), + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +function getWindowsPowerShellLifecycleCommand(scriptPath: string): string { const scriptFileName = win32.basename(scriptPath) - // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). const quotedRelativePath = quotePowerShellLiteral(`.orca\\agent-hooks\\${scriptFileName}`) - // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). - const innerCommand = + return ( `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + 'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' + "[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0" - return { - type: 'command', - command: wrapWindowsPowerShellEncodedCommand(innerCommand), - timeout: MANAGED_HOOK_TIMEOUT_SECONDS - } + ) } export function hasSameManagedHookInvocation( @@ -211,6 +223,7 @@ export function hasSameManagedHookInvocation( ): boolean { return ( actual.command === expected.command && + actual.shell === expected.shell && JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? []) ) } @@ -228,7 +241,9 @@ export function applyManagedHooks( const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher(scriptFileName) const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion) - const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS + const events = + options.events ?? + (sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS) for (const event of events) { const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] @@ -240,7 +255,7 @@ export function applyManagedHooks( nextHooks[event.eventName] = [...cleaned, definition] } - if (!sessionEndCapable) { + if (!sessionEndCapable && !options.events) { const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : [] const cleaned = removeManagedCommands(current, isManagedCommand) if (cleaned.length === 0) { diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 0f2945a9576..dca03b69a05 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,9 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. + 'qoder-no-account': 2, + 'qoder-ready': 2, // Codex 0.157 header border restores with an extra attribute bit (STA-8628 fixtures). 'codex-0157-config-override-embedded-warning': 22, 'codex-0157-effort-override-embedded-warning': 4, diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 9cd715c3a73..59b7eea3cbc 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,3 +1,4 @@ +import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' import { ipcMain } from 'electron' import { type AgentTrustPreset, @@ -42,6 +43,8 @@ export function registerAgentTrustHandlers(): void { }), { preset: args.preset, workspacePath: args.workspacePath } ) + } else if (args.preset === 'qoder') { + markQoderWorkspaceTrusted(args.workspacePath) } else if (args.preset === 'cursor') { markCursorWorkspaceTrusted(args.workspacePath) } else if (args.preset === 'copilot') { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index bce08fd5ece..767c6ece246 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1,3 +1,4 @@ +import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' /* eslint-disable max-lines */ // Why: worktree create helpers (local + remote) split out of worktrees.ts; the cohesive create flow runs this file just over the per-file line limit. @@ -439,7 +440,9 @@ async function spawnLocalStartupAndSetupTerminals(args: { if (isTuiAgent(createdWithAgent)) { const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust try { - if (preset === 'cursor') { + if (preset === 'qoder') { + markQoderWorkspaceTrusted(worktree.path) + } else if (preset === 'cursor') { markCursorWorkspaceTrusted(worktree.path) } else if (preset === 'copilot') { markCopilotFolderTrusted(worktree.path) diff --git a/src/main/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts new file mode 100644 index 00000000000..7f1d2674f9c --- /dev/null +++ b/src/main/qoder/hook-service.test.ts @@ -0,0 +1,92 @@ +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 { getManagedLifecycleHook, hasSameManagedHookInvocation } from '../claude/hook-settings' +import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { qoderHookService, QODER_HOOK_EVENTS } from './hook-service' +import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-')) + mkdirSync(join(sandbox.home, '.qoder')) +}) +afterAll(() => { + rmSync(sandbox.home, { recursive: true, force: true }) +}) + +describe('Qoder managed configuration', () => { + it('preserves user hooks and trust, installs idempotently, then removes only Orca hooks', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + writeFileSync( + path, + JSON.stringify({ + model: 'custom', + hooks: { Stop: [userHook] }, + permissions: { trustDirectories: ['/existing'] } + }) + ) + expect(qoderHookService.install().state).toBe('installed') + expect(qoderHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of QODER_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.hooks.TeammateIdle).toBeUndefined() + expect(installed.statusLine).toBeUndefined() + markQoderWorkspaceTrusted('/new-workspace') + const trusted = JSON.parse(readFileSync(path, 'utf8')) + expect(trusted.permissions.trustDirectories).toEqual(['/existing', '/new-workspace']) + expect(trusted.hooks).toEqual(installed.hooks) + expect(qoderHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ + model: 'custom', + hooks: { Stop: [userHook] } + }) + }) + + it('refuses malformed settings instead of overwriting them', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + writeFileSync(path, '{broken') + expect(qoderHookService.install().state).toBe('error') + markQoderWorkspaceTrusted('/new-workspace') + expect(readFileSync(path, 'utf8')).toBe('{broken') + expect(withQoderTrustedWorkspace({ permissions: 'invalid' }, '/workspace')).toBeNull() + expect( + withQoderTrustedWorkspace({ permissions: { trustDirectories: true } }, '/workspace') + ).toBeNull() + }) +}) + +describe('Qoder Windows hook shell', () => { + it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + const hook = getManagedLifecycleHook( + 'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', + { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + { gitBashAvailable: true } + ) + expect(hook.shell).toBe('powershell') + expect(hook.command).toContain('$env:USERPROFILE') + expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/) + expect(createManagedCommandMatcher('qoder-hook.cmd')(hook.command)).toBe(true) + expect(hasSameManagedHookInvocation({ ...hook, shell: undefined }, hook)).toBe(false) + } finally { + vi.restoreAllMocks() + } + }) +}) diff --git a/src/main/qoder/hook-service.ts b/src/main/qoder/hook-service.ts new file mode 100644 index 00000000000..d6950ef3235 --- /dev/null +++ b/src/main/qoder/hook-service.ts @@ -0,0 +1,29 @@ +import { ClaudeHookService } from '../claude/hook-service' + +// Qoder documents Claude-shaped hooks at https://docs.qoder.com/cli/hooks. +export const QODER_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification', + 'PostCompact' +] as const + +export const qoderHookService = new ClaudeHookService({ + agent: 'qoder', + source: 'qoder', + displayName: 'Qoder CLI', + settings: { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + events: QODER_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })) +}) diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts new file mode 100644 index 00000000000..2a834f7cbcb --- /dev/null +++ b/src/main/qoder/workspace-trust.ts @@ -0,0 +1,78 @@ +import { realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, posix } from 'node:path' +import { isPlainObject, readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils' +import { parseHooksJsonText } from '../agent-hooks/hooks-json-read' +import { isENOENT } from '../ipc/filesystem-path-containment' +import type { IFilesystemProvider } from '../providers/types' + +// Qoder 1.1.64 writes this exact shape after accepting its folder-trust prompt. +export function withQoderTrustedWorkspace( + config: Record, + workspacePath: string +): Record | null { + if (config.permissions !== undefined && !isPlainObject(config.permissions)) { + return null + } + const permissions = isPlainObject(config.permissions) ? config.permissions : {} + if (permissions.trustDirectories !== undefined && !Array.isArray(permissions.trustDirectories)) { + return null + } + const existing = Array.isArray(permissions.trustDirectories) ? permissions.trustDirectories : [] + if (existing.includes(workspacePath)) { + return config + } + return { + ...config, + permissions: { ...permissions, trustDirectories: [...existing, workspacePath] } + } +} + +export function markQoderWorkspaceTrusted(workspacePath: string): void { + let canonicalPath = workspacePath + try { + canonicalPath = realpathSync.native(workspacePath) + } catch { + /* Keep the supplied path when absent. */ + } + const configPath = join(homedir(), '.qoder', 'settings.json') + const config = readHooksJson(configPath) + if (!config) { + return + } + const updated = withQoderTrustedWorkspace(config, canonicalPath) + if (updated && updated !== config) { + writeHooksJson(configPath, updated) + } +} + +export async function markRemoteQoderWorkspaceTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const configDir = posix.join(remoteHome, '.qoder') + const configPath = posix.join(configDir, 'settings.json') + let config: Record | null + try { + const result = await fsProvider.readFile(configPath) + if (result.isBinary) { + return + } + config = parseHooksJsonText(result.content) + } catch (error) { + if (!isENOENT(error)) { + return + } + config = {} + } + if (!config) { + return + } + const updated = withQoderTrustedWorkspace(config, workspacePath) + if (!updated || updated === config) { + return + } + await fsProvider.createDir(configDir) + await fsProvider.writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`) +} diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index d1ff9ee24ca..8b552e55277 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -160,3 +160,66 @@ describe('markRemoteAgentWorkspaceTrusted', () => { expect(fsProvider.writeFile).not.toHaveBeenCalled() }) }) + +describe('remote Qoder trust', () => { + it('preserves settings and hooks while trusting the execution host canonical folder', async () => { + const original = { + model: 'custom', + hooks: { Stop: [] }, + permissions: { trustDirectories: ['/old'], other: true } + } + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ content: JSON.stringify(original), isBinary: false })) + }) + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: '/home/remote' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + await markRemoteAgentWorkspaceTrusted({ + preset: 'qoder', + connectionId: 'ssh-qoder', + workspacePath: '/folder' + }) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/remote/.qoder/settings.json', + `${JSON.stringify( + { + ...original, + permissions: { ...original.permissions, trustDirectories: ['/old', '/real/folder'] } + }, + null, + 2 + )}\n` + ) + }) + + it.each(['malformed', 'denied', 'binary', 'trusted'])( + 'does not overwrite %s remote settings', + async (kind) => { + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => { + if (kind === 'denied') { + throw Object.assign(new Error('denied'), { code: 'EACCES' }) + } + return { + content: + kind === 'trusted' + ? JSON.stringify({ permissions: { trustDirectories: ['/real/folder'] } }) + : '{broken', + isBinary: kind === 'binary' + } + }) + }) + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: '/home/remote' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + await markRemoteAgentWorkspaceTrusted({ + preset: 'qoder', + connectionId: 'ssh-qoder', + workspacePath: '/folder' + }) + expect(fsProvider.writeFile).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index a9ccb382c09..9ca8d665e06 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,3 +1,4 @@ +import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust' import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' import { getActiveMultiplexer } from './ssh/ssh-target-registry' @@ -20,7 +21,9 @@ export async function markRemoteAgentWorkspaceTrusted(args: { } const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) - if (args.preset === 'codex') { + if (args.preset === 'qoder') { + await markRemoteQoderWorkspaceTrusted(fsProvider, home, workspacePath) + } else if (args.preset === 'codex') { await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath) } else if (args.preset === 'cursor') { await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath) diff --git a/src/main/runtime/__fixtures__/qoder-no-account.meta.json b/src/main/runtime/__fixtures__/qoder-no-account.meta.json new file mode 100644 index 00000000000..e434627627c --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-28T08:03:27.131Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace", + "--prompt-interactive", + "Reply with hello." + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated trusted folder; no account; lifecycle hook capture", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-no-account.txt b/src/main/runtime/__fixtures__/qoder-no-account.txt new file mode 100644 index 00000000000..9c675342559 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Authenticating +──────────────────────────────────────────────────────────────────────────────────────────────────── + +⠋Waitingforauthentication... + +Escback +[>0q Welcome to Qoder CLI Signintogetstarted,orexittheapplication.  ❯1.Signintocontinue  2. Exittheapplication + +↑/↓navigate·Enterselect·Escback +]0;✦ Qoder CLI | Working   > Reply with hello.   Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +]0;✦ Reply with hello. | Working ]0;◇ Reply with hello. | Ready   x Qoder authenticationisnotready.Pleasesigninagain(e.g.via/login)beforesendinga  message.  Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +  + + + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-ready.meta.json b/src/main/runtime/__fixtures__/qoder-ready.meta.json new file mode 100644 index 00000000000..d197b691db4 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:15:00.339Z", + "platform": "darwin", + "command": ["qodercli", "--cwd", "/tmp/orca-qoder-probe/workspace"], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; existing browser-login config; credit limit reached; no prompt submitted", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-ready.txt b/src/main/runtime/__fixtures__/qoder-ready.txt new file mode 100644 index 00000000000..e198f8ff3bb --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted──────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████SignedinBrowserLogin│4.Bespecificforthebestresults│ +╰─────────────────────────────────────────────────────────╯ + + +?forshortcuts +──────────────────────────────────────────────────────────────────────────────────────────────────── +Shift+TabtoAcceptEdits +──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Model·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace +[>0q Qwen3.8-Max Model · ctx░░░░░░░░░░ 0% · /privte/tmp/oca-qoder-robe/workspace + ●Warning:Truecolor(24-bit)supportnotdetected.Usingaterminalwithtruecolor enabled will  result in a better visual experience.   ? for shortcuts ────────────────────────────────────────────────────────────────────────────────────────────────────  Shift+Tab to Accept Edits 1 MCP server ──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Qwen3.8-MaxModel·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace + 1MCPserver · 34kills + + + + + Creditsexhausted.Use/usagefordetailsor/upgradefor more. + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json new file mode 100644 index 00000000000..f028a668af3 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-28T07:56:42.619Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace" + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated configuration; no account", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.txt b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt new file mode 100644 index 00000000000..039f945f776 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt @@ -0,0 +1,58 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Doyoutrustthefilesinthisfolder? +──────────────────────────────────────────────────────────────────────────────────────────────────── +Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan +read,modify,andexecutefileshere,andwillloadlocalconfigurations(commands,hooks,MCP +servers,agents,skills,settings). + +/private/tmp/orca-qoder-probe/workspace + +❯1.Trustfolder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback +[>0q  ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwill  result in a better visual experience.   Do you trust the files in this folder? ──────────────────────────────────────────────────────────────────────────────────────────────────── Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan read, modify, and execute files here, andwillloadlocalconfigurations(commands,hooks,MCP servers,agents,skills,settings).  /private/tmp/orca-qoder-probe/workspace  ❯ 1. Trust folder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback + \ No newline at end of file diff --git a/src/main/runtime/qoder-terminal-readiness.ts b/src/main/runtime/qoder-terminal-readiness.ts new file mode 100644 index 00000000000..781da3341c0 --- /dev/null +++ b/src/main/runtime/qoder-terminal-readiness.ts @@ -0,0 +1,11 @@ +// Captured Qoder 1.1.64 paints its idle OSC title even while the trust menu owns input. +export function isQoderComposerReady(screenLines: readonly string[] | null): boolean { + if (!screenLines) { + return false + } + const screen = screenLines.join('\n').toLowerCase() + return ( + screen.includes('type your message or @path/to/file') && + !screen.includes("don't trust and exit") + ) +} diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts new file mode 100644 index 00000000000..89c5881382a --- /dev/null +++ b/src/main/runtime/qoder-transcripts.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + const readiness = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 600 }) + .catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + const readiness = await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 1500 + }) + expect(readiness.satisfied).toBe(true) + } + } + ) +}) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a203d170966..10f335a4b05 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -1,3 +1,4 @@ +import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' import { tuiAgentToAgentKind } from '../../shared/agent-kind' import type { Repo } from '../../shared/repo-types' @@ -195,7 +196,9 @@ export async function markLocalWorktreeTrusted( return } try { - if (preset === 'cursor') { + if (preset === 'qoder') { + markQoderWorkspaceTrusted(workspacePath) + } else if (preset === 'cursor') { markCursorWorkspaceTrusted(workspacePath) } else if (preset === 'copilot') { markCopilotFolderTrusted(workspacePath) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 454739471b7..bb102a3e7b8 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,3 +1,4 @@ +import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' import { detectAgentStatusFromTitle, @@ -61,6 +62,9 @@ export function isKnownReadyPromptBody( agent: TuiAgent | null, readScreenLines: () => readonly string[] | null ): boolean { + if (agent === 'qoder') { + return isQoderComposerReady(readScreenLines()) + } if (isKnownReadyPromptPreview(waitText)) { return true } diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 4b9eb120c93..87027ab4bc3 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -234,6 +234,18 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (blockedReason) { return { kind: 'blocked', reason: blockedReason } } + // Qoder publishes "Ready" before its trust dialog is dismissed; only its composer proves input is live. + if (input.agent === 'qoder') { + if ( + hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) || + input.record.lastAgentStatus === 'working' + ) { + return WORKING + } + return input.readPositiveBodyEvidence() + ? READY_STRONG + : { kind: 'pending', quietForeground: false } + } // Why the title before the body: both are tier 1, so either settles, but the title is a // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { @@ -325,7 +337,7 @@ export function ptyTuiIdleEvidence( record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => - source.getAdoptedPtyIdleStatus(pty) === 'idle' || + (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()), agent, diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7c538a990f1..8cd8fb6a251 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -50,7 +50,7 @@ export type AgentStatusApi = { export type AgentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' workspacePath: string connectionId?: string }) => Promise diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index a9afa9c363d..9d87c62eb49 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -3,7 +3,7 @@ import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts index 8a496052335..157d082a919 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -1,3 +1,4 @@ +import { isQoderTerminalTitle } from '../../../../shared/qoder-terminal-title' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -21,6 +22,7 @@ export function titleHasExplicitAgentIdentity(title: string): boolean { title.startsWith('. ') || title.startsWith('* ') || title.startsWith('\u2733') || + isQoderTerminalTitle(title) || isGeminiTerminalTitle(title) || isPiTerminalTitle(title) ) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 870ac648c40..033e0ecbb23 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -612,6 +612,7 @@ "060d152fb5": "Trae", "muse_label": "Muse", "dsh_label": "DeepSeek Harness", + "qoder_label": "Qoder CLI", "zcode_label": "ZCode", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 0ba7d7332ce..56cd2180c72 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -134,6 +134,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => searchAliases: ['deepseek', 'dsh', 'dst', 'deepseek harness'], homepageUrl: 'https://deepseek-harness.github.io/deepseek-harness/' }, + { + id: 'qoder', + label: translate('auto.lib.agent.catalog.qoder_label', 'Qoder CLI'), + cmd: 'qodercli', + faviconDomain: 'qoder.com', + homepageUrl: 'https://docs.qoder.com/cli/overview' + }, { id: 'zcode', label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index 8cc36a9a70e..b041965736e 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -1,3 +1,4 @@ +import qoderUrl from '../../../shared/agent-icons/qoder.png?url' import type { TuiAgent } from '../../../shared/tui-agent' import grokUrl from '../../../shared/agent-icons/grok.png?url' import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url' @@ -42,6 +43,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { ante: anteUrl, trae: traeUrl, 'prime-agent': primeAgentUrl, + qoder: qoderUrl, gemini: geminiUrl, antigravity: antigravityUrl, goose: gooseUrl, diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 9a05ff92f4f..41ccc2cc7d7 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -109,6 +109,7 @@ const ICONABLE_AGENT_TYPES: Record = { pi: true, omp: true, 'prime-agent': true, + qoder: true, gemini: true, antigravity: true, aider: 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 013bf239824..24393546a81 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 @@ -1,6 +1,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_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, codex: undefined, + qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, antigravity: undefined, opencode: 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 f6cd4b46902..efec3fe38b1 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_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 @@ 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, codex: undefined, + qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, antigravity: undefined, opencode: undefined, diff --git a/src/shared/__fixtures__/qoder-no-account-hooks.jsonl b/src/shared/__fixtures__/qoder-no-account-hooks.jsonl new file mode 100644 index 00000000000..0508c506368 --- /dev/null +++ b/src/shared/__fixtures__/qoder-no-account-hooks.jsonl @@ -0,0 +1,6 @@ +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"startup"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"UserPromptSubmit","permission_mode":"default","prompt":"Reply with hello.","request_set_id":"00000000-0000-4000-8000-000000000000"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"Notification","permission_mode":"default","notification_type":"idle_prompt","message":"Qoder CLI finished responding and is awaiting input.","details":{"streamingState":"idle"}} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"resume"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"} diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 1af7e65d791..f3f67535963 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,3 +1,4 @@ +import { isQoderHeadlessCommand } from './qoder-headless-command' import { isAnteHeadlessOneShotCommand } from './ante-headless-command' import { isDshNonInteractiveCommand } from './dsh-launch-command' import { isMuseHeadlessOneShotCommand } from './muse-headless-command' @@ -15,6 +16,7 @@ import type { TuiAgent } from './tui-agent' const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { + qoder: isQoderHeadlessCommand, claude: isPrintModeHeadlessOneShotCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index df8b204cda1..067e4552212 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -1,3 +1,4 @@ +import { normalizeQoderEvent } from './providers/qoder-events' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' @@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: { case 'devin': payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'qoder': + payload = normalizeQoderEvent(state, eventName, promptText, paneKey, hookPayload) + break case 'kimi': payload = normalizeKimiEvent(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 f64ece8cf36..40e59a1d820 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -25,6 +25,7 @@ import { extractHermesToolFields } from './providers/hermes-tool-fields' export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. switch (source) { + case 'qoder': case 'claude': // Why: SessionStart lands an idle row (STA-3386) and must also drop stale // tool/prompt caches left by the pane's previous session. @@ -138,6 +139,7 @@ export function extractToolFields( ): ToolSnapshot { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. switch (source) { + case 'qoder': case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through diff --git a/src/shared/agent-hook-listener/providers/qoder-events.ts b/src/shared/agent-hook-listener/providers/qoder-events.ts new file mode 100644 index 00000000000..bdf2a622dbe --- /dev/null +++ b/src/shared/agent-hook-listener/providers/qoder-events.ts @@ -0,0 +1,87 @@ +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 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, + eventName: unknown, + 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( + state, + paneKey, + extractToolFields('qoder', eventName, hookPayload), + { resetOnNewTurn } + ) + 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 ec1200b4a02..2b7f0147e60 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -5,6 +5,7 @@ import type { AgentHookSource } from '../agent-hook-relay' export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ '/hook/claude': 'claude', '/hook/codex': 'codex', + '/hook/qoder': 'qoder', '/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 b4a878f9cec..7e0d452a66b 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -37,6 +37,7 @@ import type { AgentHookTarget } from './agent-hook-types' const AGENT_HOOK_SOURCES = [ 'claude', 'codex', + 'qoder', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 59e555f8238..cc83527fc09 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -7,6 +7,7 @@ export const AGENT_HOOK_TARGETS = [ 'claude', 'openclaude', 'codex', + 'qoder', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-icons/qoder.png b/src/shared/agent-icons/qoder.png new file mode 100644 index 00000000000..42c952ed33a Binary files /dev/null and b/src/shared/agent-icons/qoder.png differ diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 6d26c99abc2..375bac1bd2e 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -25,6 +25,7 @@ const TUI_AGENT_KIND_BY_AGENT = { pi: 'pi', omp: 'omp', 'prime-agent': 'prime-agent', + qoder: 'qoder', gemini: 'gemini', antigravity: 'antigravity', aider: 'aider', diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index a81ea513674..a8262eb4760 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -73,6 +73,10 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { if (normalized.startsWith('codex-')) { return PROCESS_TO_AGENT.get('codex') } + // Qoder's launcher resolves to a versioned native binary. + if (/^qodercli-\d/.test(normalized)) { + return PROCESS_TO_AGENT.get('qodercli') + } if (normalized.startsWith('grok-')) { return PROCESS_TO_AGENT.get('grok') } @@ -158,6 +162,7 @@ export function isExpectedAgentProcess( return ( normalizedProcess === normalizedExpected || normalizedProcess.startsWith(`${normalizedExpected}.`) || + (normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) || (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) ) } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 451d10c5185..4cbc240c764 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -6,6 +6,7 @@ import type { TuiAgent } from './tui-agent' export const RESUMABLE_TUI_AGENTS = [ 'claude', 'codex', + 'qoder', 'gemini', 'antigravity', 'opencode', @@ -194,6 +195,7 @@ export function extractAgentProviderSession( // Native-chat agents: also capture the hook's authoritative transcript_path, // 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 'claude': case 'codex': { const id = readSessionId(payload, ['session_id']) @@ -279,6 +281,8 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null case 'codex': return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null + case 'qoder': + return providerSession.key === 'session_id' ? ['qodercli', '--resume', id] : null case 'gemini': return providerSession.key === 'session_id' ? ['gemini', '--resume', id] : null case 'antigravity': diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 563ca6bcf1a..a3bc238a7e1 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -1,3 +1,6 @@ +import { isDshTerminalTitle } from './dsh-terminal-title' +export { DSH_WHALE, isDshTerminalTitle } from './dsh-terminal-title' +import { isQoderTerminalTitle } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, @@ -21,17 +24,6 @@ export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( 'i' ) -/** DSH-TUI titles are always ` 🐋 ` (its Chat screen's - * `useTerminalTitle`), and the whale is the only part no other agent emits. It has to - * outrank the Gemini glyphs below: DSH's IDLE prefix is `✦`, which is Gemini's WORKING - * glyph, so without this a resting DSH pane reads as a working Gemini — wrong agent, - * wrong state. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. */ -export const DSH_WHALE = '\u{1F40B}' // 🐋 - -export function isDshTerminalTitle(title: string): boolean { - return title.includes(DSH_WHALE) -} - export const GEMINI_WORKING = '\u2726' // ✦ export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ export const GEMINI_IDLE = '\u25c7' // ◇ @@ -68,6 +60,9 @@ export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g function computeIsGeminiTerminalTitle(title: string): boolean { // Why first: see isDshTerminalTitle — the two agents share the `✦` glyph. + if (isQoderTerminalTitle(title)) { + return false + } if (isDshTerminalTitle(title)) { return false } diff --git a/src/shared/agent-title-decoration.test.ts b/src/shared/agent-title-decoration.test.ts index a87426c83cf..5a5666696e0 100644 --- a/src/shared/agent-title-decoration.test.ts +++ b/src/shared/agent-title-decoration.test.ts @@ -5,6 +5,11 @@ import { } from './agent-title-decoration' describe('stripLeadingAgentTitleDecoration', () => { + it('strips Qoder permission decoration without stripping triangles inside a title', () => { + expect(stripLeadingAgentTitleDecoration('▲ Qoder CLI')).toBe('Qoder CLI') + expect(stripLeadingAgentTitleDecoration('deploy ▲ complete')).toBe('deploy ▲ complete') + }) + it("strips Claude's ✳ idle glyph", () => { expect(stripLeadingAgentTitleDecoration('✳ Claude Code')).toBe('Claude Code') }) diff --git a/src/shared/agent-title-decoration.ts b/src/shared/agent-title-decoration.ts index 47a6db679e7..3ae4d0075e6 100644 --- a/src/shared/agent-title-decoration.ts +++ b/src/shared/agent-title-decoration.ts @@ -1,11 +1,11 @@ // Leading status decorations that coding agents prepend to their OSC title — -// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), braille and quarter-circle spinners, +// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), Qoder’s ▲, braille and quarter-circle spinners, // and Claude's '. '/'* ' working/idle prefixes. Once the tab bar shows the // provider icon, this leading glyph reads as a redundant second icon, so strip // it from the displayed title. Scoped to titles we already know belong to an agent. const LEADING_AGENT_TITLE_DECORATION_RE = // eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges - /^(?:[✳✦⏲◇✋⠀-⣿◐-◓]+|[.*]\s)\s*/ + /^(?:[✳✦⏲◇✋▲⠀-⣿◐-◓]+|[.*]\s)\s*/ export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string { return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart() diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 949768e03b5..69d1531fb9e 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { isQoderTerminalTitle } from './qoder-terminal-title' import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, @@ -72,6 +73,9 @@ function computeAgentLabel(title: string): string | null { if (piStateBrand) { return piStateBrand } + if (isQoderTerminalTitle(title)) { + return 'Qoder CLI' + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 1c92ca4070d..7cd7a2c292c 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -1,3 +1,4 @@ +import { qoderTitleStatus } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, BRAILLE_SPINNER_RE, @@ -129,6 +130,10 @@ export function createAgentStatusTracker( * Normalize high-churn agent titles into stable display labels before storage. */ export function normalizeTerminalTitle(title: string): string { + const qoderStatus = qoderTitleStatus(title) + if (qoderStatus) { + return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} Qoder CLI` + } if (!title) { return title } @@ -181,6 +186,10 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { } function computeAgentStatusFromTitle(title: string): AgentStatus | null { + const qoderStatus = qoderTitleStatus(title) + if (qoderStatus) { + return qoderStatus + } if (!title || isClaudeManagementTitle(title)) { return null } diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index deb8f5cbbe2..a4d70a76037 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -6,6 +6,8 @@ const WELL_KNOWN_LABELS: Record = { claude: 'Claude', openclaude: 'OpenClaude', codex: 'Codex', + qoder: 'Qoder CLI', + qodercli: 'Qoder CLI', gemini: 'Gemini', antigravity: 'Antigravity', amp: 'Amp', diff --git a/src/shared/dsh-terminal-title.ts b/src/shared/dsh-terminal-title.ts new file mode 100644 index 00000000000..76662bd10d0 --- /dev/null +++ b/src/shared/dsh-terminal-title.ts @@ -0,0 +1,10 @@ +/** DSH-TUI titles are always ` 🐋 ` (its Chat screen's + * `useTerminalTitle`), and the whale is the only part no other agent emits. It has to + * outrank the Gemini glyphs below: DSH's IDLE prefix is `✦`, which is Gemini's WORKING + * glyph, so without this a resting DSH pane reads as a working Gemini — wrong agent, + * wrong state. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. */ +export const DSH_WHALE = '\u{1F40B}' // 🐋 + +export function isDshTerminalTitle(title: string): boolean { + return title.includes(DSH_WHALE) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 6861d951f98..5dcc8c3b52b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -235,6 +235,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_QODER_RESUME_RUNTIME_CAPABILITY = + 'agent-session.qoder-resume.v1' as const export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY = 'agent-session.zcode-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. @@ -398,6 +400,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_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/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts new file mode 100644 index 00000000000..8db665e7209 --- /dev/null +++ b/src/shared/qoder-agent.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + recognizeAgentProcessFromCommandLine, + isExpectedAgentProcess +} from './agent-process-recognition' +import { + getAgentLabel, + isGeminiTerminalTitle, + normalizeTerminalTitle, + detectAgentStatusFromTitle +} from './agent-detection' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { getAgentResumeArgv } from './agent-session-resume' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' + +describe('Qoder agent identity and lifecycle', () => { + it('recognizes the installed versioned binary on native platforms and excludes headless runs', () => { + for (const command of [ + 'qodercli', + '/home/dev/.qoder/bin/qodercli/qodercli-1.1.64', + 'C:\\Qoder\\qodercli-1.1.64.exe' + ]) { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder') + expect(isExpectedAgentProcess(command, 'qodercli')).toBe(true) + } + expect(recognizeAgentProcessFromCommandLine('qodercli --print hello')).toBeNull() + for (const args of [ + '-o json', + '--input-format=stream-json', + '--remote-control session', + '--acp', + '--delete-session 1' + ]) { + expect(recognizeAgentProcessFromCommandLine(`qodercli ${args}`)).toBeNull() + } + expect(recognizeAgentProcessFromCommandLine('qodercli --prompt-interactive hello')?.agent).toBe( + 'qoder' + ) + expect(recognizeAgentProcessFromCommandLine('qodercli-unrelated')).toBeNull() + }) + + it.each([ + ['◇ Qoder CLI | Ready', 'idle'], + ['✦ Implement feature | Thinking', 'working'], + ['▲ Implement feature | Confirm', 'permission'] + ] as const)('keeps %s attributed through title storage', (title, status) => { + const stored = normalizeTerminalTitle(title.padEnd(80)) + expect(getAgentLabel(stored)).toBe('Qoder CLI') + expect(isGeminiTerminalTitle(title)).toBe(false) + expect(detectAgentStatusFromTitle(stored)).toBe(status) + expect(normalizeTerminalTitle(stored)).toBe(stored) + }) + + it('keeps upstream Gemini titles and arbitrary triangle text unchanged', () => { + expect(isGeminiTerminalTitle('✦ Working (project)')).toBe(true) + expect(getAgentLabel('deploy ▲ complete')).not.toBe('Qoder CLI') + }) + + it('tracks start, work, permission, completion and resume without becoming Claude', () => { + const state = createHookListenerState() + const session = { session_id: 'qoder-session', transcript_path: '/tmp/qoder-session.jsonl' } + const send = (hook_event_name: string, extra = {}) => + normalizeAndAccept(state, 'qoder', { ...session, hook_event_name, ...extra }) + const started = send('SessionStart', { source: 'startup' }) + expect(started?.payload).toMatchObject({ + agentType: 'qoder', + state: 'done', + sessionBoundary: true + }) + expect(started?.providerSession).toMatchObject({ id: 'qoder-session' }) + expect(send('UserPromptSubmit', { prompt: 'Fix the bug' })?.payload).toMatchObject({ + state: 'working', + agentType: 'qoder', + prompt: 'Fix the bug' + }) + expect( + send('PermissionRequest', { tool_name: 'Bash', tool_input: { command: 'pwd' } })?.payload + ).toMatchObject({ state: 'waiting', toolName: 'Bash' }) + expect(send('PostToolUse', { tool_name: 'Bash' })?.payload.state).toBe('working') + expect(send('SessionStart', { source: 'compact' })).toBeNull() + expect(send('Stop', { last_assistant_message: 'Fixed.' })?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Fixed.' + }) + expect(send('StopFailure', { error: 'Unavailable' })?.payload.mainAgent).toMatchObject({ + outcome: 'failure' + }) + expect(getAgentResumeArgv('qoder', { key: 'session_id', id: 'qoder-session' })).toEqual([ + 'qodercli', + '--resume', + 'qoder-session' + ]) + }) +}) + +it('replays captured unauthenticated startup, prompt, idle and resume hooks', () => { + const state = createHookListenerState() + const lines = readFileSync( + join(__dirname, '__fixtures__', 'qoder-no-account-hooks.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + const events = lines.map((line) => normalizeAndAccept(state, 'qoder', JSON.parse(line))) + expect(events.map((event) => event?.payload.state)).toEqual([ + 'done', + 'working', + 'done', + 'done', + 'done', + 'done' + ]) + expect(events.every((event) => event?.payload.agentType === 'qoder')).toBe(true) + expect(events[4]?.payload.sessionBoundary).toBe(true) + expect(events[4]?.providerSession?.id).toBe(events[0]?.providerSession?.id) +}) + +it.each(['darwin', 'linux', 'win32'] as const)( + 'launches a Qoder prompt safely on %s', + (platform) => { + const plan = buildAgentStartupPlan({ + agent: 'qoder', + prompt: "fix Bob's branch; echo $HOME", + cmdOverrides: {}, + platform + }) + expect(plan?.expectedProcess).toBe('qodercli') + expect(plan?.followupPrompt).toBeNull() + expect(plan?.launchCommand).toBe( + platform === 'win32' + ? "qodercli --prompt-interactive 'fix Bob''s branch; echo $HOME'" + : `qodercli --prompt-interactive 'fix Bob'"'"'s branch; echo $HOME'` + ) + } +) + +it('does not rewrite DSH titles containing a pipe as Qoder', () => { + const title = '✦ 🐋 refactor parser | run tests' + expect(normalizeTerminalTitle(title)).toBe(title) + expect(getAgentLabel(title)).not.toBe('Qoder CLI') + expect(detectAgentStatusFromTitle(title)).not.toBe('working') +}) + +it('settles manual compaction without interrupting automatic compaction', () => { + const state = createHookListenerState() + const send = (hook_event_name: string, extra = {}) => + normalizeAndAccept(state, 'qoder', { hook_event_name, ...extra }) + expect(send('UserPromptSubmit', { prompt: 'Refactor' })?.payload.state).toBe('working') + expect(send('PreCompact', { trigger: 'manual' })).toBeNull() + expect(send('PostCompact', { trigger: 'auto' })).toBeNull() + expect(send('SessionStart', { source: 'compact' })).toBeNull() + expect(send('PostCompact', { trigger: 'manual' })?.payload).toMatchObject({ + state: 'done', + sessionBoundary: true, + agentType: 'qoder' + }) +}) diff --git a/src/shared/qoder-headless-command.ts b/src/shared/qoder-headless-command.ts new file mode 100644 index 00000000000..6ffb16ca9b3 --- /dev/null +++ b/src/shared/qoder-headless-command.ts @@ -0,0 +1,31 @@ +import { isPrintModeHeadlessOneShotCommand, optionName } from './print-mode-headless-command' + +export function isQoderHeadlessCommand(tokens: readonly string[]): boolean { + if (isPrintModeHeadlessOneShotCommand(tokens)) { + return true + } + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + return false + } + const name = optionName(token) + const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[index + 1] + if (name === '-o' && (value === 'json' || value === 'stream-json')) { + return true + } + if (name === '--input-format' && value === 'stream-json') { + return true + } + if ( + name === '--remote-control' || + name === '--remote' || + name === '--list-sessions' || + name === '--delete-session' || + name === '--acp' + ) { + return true + } + } + return false +} diff --git a/src/shared/qoder-terminal-title.ts b/src/shared/qoder-terminal-title.ts new file mode 100644 index 00000000000..e21b9dbb0e3 --- /dev/null +++ b/src/shared/qoder-terminal-title.ts @@ -0,0 +1,21 @@ +import { isDshTerminalTitle } from './dsh-terminal-title' + +// Qoder 1.1.64: one space after the glyph, then a session title and a pipe-delimited status. +const QODER_DYNAMIC_TITLE_RE = /^[✦◇▲] (?! ).*\s\|\s[^|]+$/ +const QODER_LABEL_RE = /^(?:[✦◇▲] )?Qoder CLI(?: CN)?(?: \(.*\))?$/ + +export function isQoderTerminalTitle(title: string): boolean { + if (isDshTerminalTitle(title)) { + return false + } + const trimmed = title.trim() + return QODER_DYNAMIC_TITLE_RE.test(trimmed) || QODER_LABEL_RE.test(trimmed) +} + +export function qoderTitleStatus(title: string): 'working' | 'permission' | 'idle' | null { + if (!isQoderTerminalTitle(title)) { + return null + } + const glyph = title.trimStart()[0] + return glyph === '✦' ? 'working' : glyph === '▲' ? 'permission' : 'idle' +} diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index ea4921acfc6..f8d1a913172 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -24,6 +24,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { pi: 'pi', omp: null, 'prime-agent': null, + qoder: 'qoder', gemini: 'gemini-cli', antigravity: 'antigravity', aider: null, diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 39735707dff..cee76b677d6 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -19,6 +19,7 @@ export const AGENT_KIND_VALUES = [ 'pi', 'omp', 'prime-agent', + 'qoder', 'gemini', 'antigravity', 'aider', diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 9ff42e2f2f7..045b0aeceec 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -1,3 +1,4 @@ +import { isQoderTerminalTitle } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, @@ -42,6 +43,9 @@ export function containsBrailleSpinner(title: string): boolean { export function isGeminiTerminalTitle(title: string): boolean { // Why: DSH-TUI's idle prefix is `✦`, Gemini's working glyph. The whale is decisive // and is checked first so a resting DSH pane never reads as a working Gemini. + if (isQoderTerminalTitle(title)) { + return false + } if (isDshTerminalTitle(title)) { return false } @@ -170,6 +174,9 @@ function computeAgentLabel(title: string): string | null { if (isDshTerminalTitle(title)) { return 'DeepSeek Harness' } + if (isQoderTerminalTitle(title)) { + return 'Qoder CLI' + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } @@ -250,6 +257,7 @@ const TITLE_LABEL_TO_AGENT: Partial> = { 'Claude Code': 'claude', OpenClaude: 'openclaude', Codex: 'codex', + 'Qoder CLI': 'qoder', 'Gemini CLI': 'gemini', 'GitHub Copilot': 'copilot', Grok: 'grok', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 9ed1751bd66..15318913968 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -41,7 +41,7 @@ export type TuiAgentConfig = { /** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */ pasteNeedsTypedRequest?: boolean /** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */ - preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ @@ -184,6 +184,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Why: Prime Agent embeds Pi's TUI and decodes CSI-u the same way (see pi above). windowsShiftEnterEncoding: 'csi-u' }, + qoder: { + detectCmd: 'qodercli', + promptInjectionMode: 'flag-prompt-interactive', + preflightTrust: 'qoder' + }, gemini: { detectCmd: 'gemini', promptInjectionMode: 'flag-prompt-interactive' diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 780db644071..a90daa3242f 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -23,6 +23,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { pi: 'Pi', omp: 'OMP', 'prime-agent': 'Prime Agent', + qoder: 'Qoder CLI', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 6d6a6bec856..3a9912bf066 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -8,6 +8,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { 'claude-agent-teams': '--dangerously-skip-permissions', openclaude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', + qoder: '--dangerously-skip-permissions', gemini: '--yolo', antigravity: '--dangerously-skip-permissions', aider: '--yes-always', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 1a5ce8702bd..85567dcb334 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -17,6 +17,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'trae', 'muse', 'dsh', + 'qoder', 'zcode', 'pi', 'omp', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 7c8be4c4cf0..19722220888 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -11,6 +11,7 @@ export type TuiAgent = | 'mimo-code' | 'pi' // Pi (pi.dev) | 'omp' // OMP (omp.sh) + | 'qoder' // Qoder CLI | 'gemini' // Gemini CLI | 'antigravity' // Google Antigravity CLI | 'aider' // Aider