From 843607b1bcc3e17782de2ee18b8dc3d3aecb1f6f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:13:26 -0700 Subject: [PATCH] Register supervised Qoder China and Qwen Code (#24616) * Add Qoder session history and search with real CLI coverage * Allow the real Qoder marker file to end with a newline * Keep Qoder tool output out of history previews and search * Keep Qoder search pages readable by older clients * Verify persisted Qoder history after a real generated and resumed task * Negotiate Qoder filters before searching an older execution host * Combine search client imports for the CI plugin gate * Keep the relay search oracle aligned with legacy agent filtering * Register supervised Qoder China and Qwen lifecycle integration * Cover Qoder China mobile assets and mixed-host resume gates * Verify Qoder provider tags against the older released wire parser * Verify China and Qwen keep independent Windows hook scripts * Verify Qoder registrations against the installed older Windows release * test(qoder): align search capability contracts and pin old-host fencing * fix(qoder): rank exact picker identities and command aliases first * test(qoder): preserve the regional CLI shared icon expectation Keep the full bundled-asset and no-remote-image checks, with an explicit shared-logo basename for Qoder China. The map also works with older catalog type unions. * fix(qoder): align China catalog entry with fallback order --------- Co-authored-by: Orca Integration Recovery --- config/tsconfig.cli.json | 1 + .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 1 + .../managed-agent-hook-registry.ts | 11 ++- .../managed-hook-command-contract.test.ts | 33 +++++++- ...ote-hook-service-registry-coverage.test.ts | 5 +- .../remote-managed-hook-installers.ts | 5 +- .../server-retired-pane-new-turn.test.ts | 2 + src/main/agent-workspace-trust-spawn.test.ts | 1 + src/main/claude/hook-settings.ts | 10 ++- src/main/claude/windows-hook-files.test.ts | 10 ++- src/main/execution-host-workspace-trust.ts | 4 + src/main/qoder/hook-service.test.ts | 52 +++++++++++- src/main/qoder/hook-service.ts | 13 +++ src/main/qoder/workspace-trust.ts | 8 +- src/main/qwen-code/hook-service.ts | 37 ++++++++ .../__fixtures__/qoder-cn-signin.meta.json | 15 ++++ .../runtime/__fixtures__/qoder-cn-signin.txt | 65 ++++++++++++++ .../__fixtures__/qoder-cn-startup.meta.json | 15 ++++ .../runtime/__fixtures__/qoder-cn-startup.txt | 65 ++++++++++++++ .../readiness-census/synthetic--qoder-cn.json | 79 ++++++++++++++++++ .../transcript--qoder-cn-signin@agent.json | 23 +++++ .../transcript--qoder-cn-signin@unknown.json | 25 ++++++ .../transcript--qoder-cn-startup@agent.json | 21 +++++ .../transcript--qoder-cn-startup@unknown.json | 23 +++++ src/main/runtime/qoder-transcripts.test.ts | 22 +++++ .../readiness-census-synthetic-matrix.ts | 1 + .../readiness-census-transcript-catalog.ts | 1 + src/main/runtime/terminal-wait-detection.ts | 2 +- .../runtime/tui-idle-evidence-source.test.ts | 40 +++++++++ src/main/runtime/tui-idle-evidence-source.ts | 4 +- src/main/runtime/tui-idle-evidence.ts | 2 +- src/main/workspace-trust-test-fixtures.ts | 2 + src/relay/agent-workspace-trust-spawn.test.ts | 6 +- .../components/agent/AgentCombobox.test.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/lib/agent-catalog.tsx | 12 ++- src/renderer/src/lib/agent-favicon-assets.ts | 1 + .../src/lib/agent-picker-search.test.ts | 35 +++++++- src/renderer/src/lib/agent-picker-search.ts | 15 ++-- src/renderer/src/lib/agent-status.ts | 1 + ...t-resume-host-authority-capability.test.ts | 4 + .../agent-resume-host-authority-capability.ts | 4 + .../remote-agent-session-launch.test.ts | 4 +- src/shared/agent-headless-command.ts | 1 + .../agent-hook-listener/provider-dispatch.ts | 15 ++++ .../provider-event-routing.ts | 4 + .../providers/compatible-lifecycle-events.ts | 2 +- .../agent-hook-listener/source-routing.ts | 2 + src/shared/agent-hook-relay.ts | 2 + src/shared/agent-hook-types.ts | 2 + src/shared/agent-icons/qoder-cn.png | Bin 0 -> 2416 bytes src/shared/agent-kind.ts | 1 + src/shared/agent-process-recognition.ts | 5 ++ ...ent-session-resume-runtime-capabilities.ts | 33 ++++++++ src/shared/agent-session-resume.ts | 8 ++ src/shared/agent-title-identity.ts | 2 +- src/shared/agent-title-status.ts | 3 +- src/shared/protocol-version.ts | 38 ++++----- src/shared/qoder-agent.test.ts | 43 ++++++++++ src/shared/qoder-cn-launch-recipe.test.ts | 49 +++++++++++ src/shared/skills-cli-agent-keys.ts | 1 + src/shared/telemetry-property-schemas.ts | 1 + src/shared/terminal-title-agent-type.ts | 4 +- src/shared/tui-agent-config-types.ts | 2 +- 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-rest-signal.ts | 3 +- src/shared/tui-agent-selection.ts | 1 + src/shared/tui-agent.ts | 1 + .../qoder-registration-downgrade.unit.test.ts | 46 ++++++++++ 73 files changed, 903 insertions(+), 63 deletions(-) create mode 100644 src/main/qwen-code/hook-service.ts create mode 100644 src/main/runtime/__fixtures__/qoder-cn-signin.meta.json create mode 100644 src/main/runtime/__fixtures__/qoder-cn-signin.txt create mode 100644 src/main/runtime/__fixtures__/qoder-cn-startup.meta.json create mode 100644 src/main/runtime/__fixtures__/qoder-cn-startup.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json create mode 100644 src/main/runtime/tui-idle-evidence-source.test.ts create mode 100644 src/shared/agent-icons/qoder-cn.png create mode 100644 src/shared/agent-session-resume-runtime-capabilities.ts create mode 100644 src/shared/qoder-cn-launch-recipe.test.ts create mode 100644 tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 2ff9a26c569..fd89c22f66c 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/qwen-code/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", diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 53745ead77e..49f27df6990 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -20,6 +20,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial> omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', qoder: 'qoder.com', + 'qoder-cn': 'qoder.cn', 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 11b642ee090..ade72125312 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -1,5 +1,6 @@ import { codebuddyHookService } from '../codebuddy/hook-service' -import { qoderHookService } from '../qoder/hook-service' +import { qwenCodeHookService } from '../qwen-code/hook-service' +import { qoderCnHookService, 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' @@ -47,6 +48,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['qoder', () => qoderHookService.install()], + ['qoder-cn', () => qoderCnHookService.install()], + ['qwen-code', () => qwenCodeHookService.install()], ['codebuddy', () => codebuddyHookService.install()], ['antigravity', () => antigravityHookService.install()], ['amp', () => ampHookService.install()], @@ -75,6 +78,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['codex', () => codexHookService.refreshManagedScripts()], ['gemini', () => geminiHookService.refreshManagedScripts()], ['qoder', () => qoderHookService.refreshManagedScripts()], + ['qoder-cn', () => qoderCnHookService.refreshManagedScripts()], + ['qwen-code', () => qwenCodeHookService.refreshManagedScripts()], ['codebuddy', () => codebuddyHookService.refreshManagedScripts()], ['antigravity', () => antigravityHookService.refreshManagedScripts()], ['cursor', () => cursorHookService.refreshManagedScripts()], @@ -95,6 +100,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['qoder', () => qoderHookService.remove()], + ['qoder-cn', () => qoderCnHookService.remove()], + ['qwen-code', () => qwenCodeHookService.remove()], ['codebuddy', () => codebuddyHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['amp', () => ampHookService.remove()], @@ -121,6 +128,8 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['qoder', () => qoderHookService.getStatus()], + ['qoder-cn', () => qoderCnHookService.getStatus()], + ['qwen-code', () => qwenCodeHookService.getStatus()], ['codebuddy', () => codebuddyHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], ['amp', () => ampHookService.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 247c50e7f90..c38c30ac77b 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -92,6 +92,34 @@ const buildersByAgent = new Map([ remote: (path) => [getClaudeRemoteCommand(path)] } ], + [ + 'qoder-cn', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.qoder-cn', + scriptBaseName: 'qoder-cn-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], + [ + 'qwen-code', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.qwen', + scriptBaseName: 'qwen-code-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], [ 'codebuddy', { @@ -233,7 +261,10 @@ describe('managed hook command contract', () => { // Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner. const scannedCommand = platform === 'win32' && - (agent === 'qoder' || agent === 'codebuddy') && + (agent === 'qoder' || + agent === 'qoder-cn' || + agent === 'qwen-code' || + agent === 'codebuddy') && command.startsWith('$scriptPath = Join-Path') ? command .replaceAll('$LASTEXITCODE', '') 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 dea6c4a4663..1fb3dc75ee8 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 @@ -23,7 +23,8 @@ 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 { qwenCodeHookService } from '../qwen-code/hook-service' +import { qoderCnHookService, 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' @@ -54,6 +55,8 @@ describe('remote hook service registry coverage', () => { ['kimi', kimiHookService], ['muse', museHookService], ['qoder', qoderHookService], + ['qoder-cn', qoderCnHookService], + ['qwen-code', qwenCodeHookService], ['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 b012af91ff3..a8367f64f19 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -1,5 +1,6 @@ import { codebuddyHookService } from '../codebuddy/hook-service' -import { qoderHookService } from '../qoder/hook-service' +import { qwenCodeHookService } from '../qwen-code/hook-service' +import { qoderCnHookService, 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' @@ -65,6 +66,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)], + ['qoder-cn', (sftp, remoteHome) => qoderCnHookService.installRemote(sftp, remoteHome)], + ['qwen-code', (sftp, remoteHome) => qwenCodeHookService.installRemote(sftp, remoteHome)], ['codebuddy', (sftp, remoteHome) => codebuddyHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['amp', (sftp, remoteHome) => ampHookService.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 23dbb56c695..30f61a0238f 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 @@ -23,6 +23,8 @@ afterEach(() => vi.restoreAllMocks()) * mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */ const NEW_TURN_EVENT: Record = { qoder: 'SessionStart', + 'qoder-cn': 'SessionStart', + 'qwen-code': 'SessionStart', claude: 'SessionStart', kimi: 'UserPromptSubmit', codebuddy: 'UserPromptSubmit', diff --git a/src/main/agent-workspace-trust-spawn.test.ts b/src/main/agent-workspace-trust-spawn.test.ts index 17204bd0f5d..b87d58ff855 100644 --- a/src/main/agent-workspace-trust-spawn.test.ts +++ b/src/main/agent-workspace-trust-spawn.test.ts @@ -47,6 +47,7 @@ describe('applyAgentWorkspaceTrustToSpawn', () => { 'cursor', 'copilot', 'qoder', + 'qoder-cn', 'antigravity' ]) ) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index bb89cf38820..40449344f20 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -18,8 +18,14 @@ import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-c import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' | '.qoder' | '.codebuddy' - scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' | 'codebuddy-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' | '.qoder-cn' | '.qwen' | '.codebuddy' + scriptBaseName: + | 'claude-hook' + | 'openclaude-hook' + | 'qoder-hook' + | 'qoder-cn-hook' + | 'qwen-code-hook' + | 'codebuddy-hook' usesWindowsCompatLauncher: boolean windowsHookShell?: 'powershell' } diff --git a/src/main/claude/windows-hook-files.test.ts b/src/main/claude/windows-hook-files.test.ts index a3d369e75ec..25bae132e16 100644 --- a/src/main/claude/windows-hook-files.test.ts +++ b/src/main/claude/windows-hook-files.test.ts @@ -9,7 +9,8 @@ import { ClaudeHookService } from './hook-service' import { getWindowsManagedLifecycleHook } from './hook-settings' import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files' import { codebuddyHookService } from '../codebuddy/hook-service' -import { qoderHookService } from '../qoder/hook-service' +import { qoderHookService, qoderCnHookService } from '../qoder/hook-service' +import { qwenCodeHookService } from '../qwen-code/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' const { home } = vi.hoisted(() => ({ home: { path: '' } })) @@ -143,15 +144,16 @@ describe('Windows Claude hook files', () => { it('keeps compatible agents on their existing single-file scripts', async () => { for (const [name, compatible] of [ ['qoder', qoderHookService], + ['qoder-cn', qoderCnHookService], + ['qwen-code', qwenCodeHookService], ['codebuddy', codebuddyHookService], ['openclaude', openClaudeHookService] ] as const) { expect(compatible.install().state).toBe('installed') await compatible.refreshManagedScripts() const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`) - expect(readFileSync(path, 'utf8')).toContain( - `/hook/${name === 'openclaude' ? 'claude' : name}` - ) + const source = name === 'openclaude' ? 'claude' : name + expect(readFileSync(path, 'utf8')).toContain(`/hook/${source}`) expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd') expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe( false diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts index 52562e68064..acb84ea7ef1 100644 --- a/src/main/execution-host-workspace-trust.ts +++ b/src/main/execution-host-workspace-trust.ts @@ -52,6 +52,7 @@ export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record cursor: false, copilot: true, qoder: true, + 'qoder-cn': true, antigravity: false } @@ -66,6 +67,7 @@ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record ({ 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 { qoderCnHookService, qoderHookService, QODER_HOOK_EVENTS } from './hook-service' import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust' +import { qwenCodeHookService, QWEN_CODE_HOOK_EVENTS } from '../qwen-code/hook-service' beforeAll(() => { sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-')) @@ -96,3 +97,52 @@ describe('Qoder Windows hook shell', () => { } }) }) + +it.each([ + { + service: qoderCnHookService, + directory: '.qoder-cn', + source: 'qoder-cn', + events: QODER_HOOK_EVENTS + }, + { + service: qwenCodeHookService, + directory: '.qwen', + source: 'qwen-code', + events: QWEN_CODE_HOOK_EVENTS + } +])( + 'installs and removes $source hooks without changing user settings', + ({ service, directory, source, events }) => { + const configDir = join(sandbox.home, directory) + mkdirSync(configDir, { recursive: true }) + const path = join(configDir, 'settings.json') + const userHook = { matcher: '', hooks: [{ type: 'command', command: 'echo user-owned' }] } + writeFileSync( + path, + JSON.stringify({ + model: 'custom', + statusLine: { command: 'user-status' }, + hooks: { Stop: [userHook] } + }) + ) + expect(service.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + expect(Object.keys(installed.hooks).sort()).toEqual([...events].sort()) + expect(installed.statusLine).toEqual({ command: 'user-status' }) + expect( + readFileSync(join(sandbox.home, '.orca', 'agent-hooks', `${source}-hook.sh`), 'utf8') + ).toContain(`/hook/${source}`) + if (source === 'qoder-cn') { + markQoderWorkspaceTrusted('/cn-workspace', sandbox.home, '.qoder-cn') + expect(JSON.parse(readFileSync(path, 'utf8')).permissions.trustDirectories).toEqual([ + '/cn-workspace' + ]) + } + expect(service.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ + model: 'custom', + hooks: { Stop: [userHook] } + }) + } +) diff --git a/src/main/qoder/hook-service.ts b/src/main/qoder/hook-service.ts index ac9e869638c..d69d6d656c7 100644 --- a/src/main/qoder/hook-service.ts +++ b/src/main/qoder/hook-service.ts @@ -36,3 +36,16 @@ export const qoderHookService = new ClaudeHookService({ }, hookPlan: QODER_MANAGED_HOOK_PLAN }) + +export const qoderCnHookService = new ClaudeHookService({ + agent: 'qoder-cn', + source: 'qoder-cn', + displayName: 'Qoder CLI China', + settings: { + configDirName: '.qoder-cn', + scriptBaseName: 'qoder-cn-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: QODER_MANAGED_HOOK_PLAN +}) diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts index bb11aaebfcf..043b3527023 100644 --- a/src/main/qoder/workspace-trust.ts +++ b/src/main/qoder/workspace-trust.ts @@ -24,14 +24,18 @@ export function withQoderTrustedWorkspace( } } -export function markQoderWorkspaceTrusted(workspacePath: string, home: string): void { +export function markQoderWorkspaceTrusted( + workspacePath: string, + home: string, + configDirName: '.qoder' | '.qoder-cn' = '.qoder' +): void { let canonicalPath = workspacePath try { canonicalPath = realpathSync.native(workspacePath) } catch { /* Keep the supplied path when absent. */ } - const configPath = join(home, '.qoder', 'settings.json') + const configPath = join(home, configDirName, 'settings.json') const config = readHooksJson(configPath) if (!config) { return diff --git a/src/main/qwen-code/hook-service.ts b/src/main/qwen-code/hook-service.ts new file mode 100644 index 00000000000..7488271380e --- /dev/null +++ b/src/main/qwen-code/hook-service.ts @@ -0,0 +1,37 @@ +import { ClaudeHookService } from '../claude/hook-service' +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' + +// Qwen's hook enum is authoritative; Claude's version table and statusline do not apply. +export const QWEN_CODE_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification', + 'SubagentStart', + 'SubagentStop' +] as const + +export const QWEN_CODE_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: QWEN_CODE_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const qwenCodeHookService = new ClaudeHookService({ + agent: 'qwen-code', + source: 'qwen-code', + displayName: 'Qwen Code', + settings: { + configDirName: '.qwen', + scriptBaseName: 'qwen-code-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: QWEN_CODE_MANAGED_HOOK_PLAN +}) diff --git a/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json b/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json new file mode 100644 index 00000000000..81ab5e8f43a --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-cn-signin.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-02T07:27:56.152Z", + "platform": "darwin", + "command": [ + "qoderclicn", + "--config-dir", + "/tmp/orca-qoder-validation-1002/cn-config", + "--cwd", + "/tmp/orca-qoder-validation-1002/workspace" + ], + "cols": 120, + "rows": 40, + "note": "Qoder CLI China 1.1.65; accepted trust in isolated config; sign-in dialog; no China account", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-cn-signin.txt b/src/main/runtime/__fixtures__/qoder-cn-signin.txt new file mode 100644 index 00000000000..81c7dd7fcd5 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-cn-signin.txt @@ -0,0 +1,65 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI CN | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI CN | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted───────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLI CN v1.1.65│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰──────────────────────────────────────────────────────────────╯ + + + Doyoutrustthefilesinthisfolder? +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLICNcanread,modify,and +executefileshere,andwillloadlocalconfigurations(commands,hooks,MCPservers,agents,skills,settings). + +/private/tmp/orca-qoder-validation-1002/workspace + +❯1.Trustfolder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback +[>0q  ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwillresultinabetter  visual experience.   Do you trust the files in this folder? ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Please confirm this is your own project or from atrustedsource.Oncetrusted,QoderCLICNcanread,modify,and executefileshere,andwillloadlocalconfigurations(commands,hooks,MCPservers,agents,skills,settings).  /private/tmp/orca-qoder-validation-1002/workspace  ❯ 1. Trust folder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback + Welcme to QoderCLI CN Sign in to getstarted, r exitthe applicatin.  ❯1.Signintocontinue  2. Exit the application ↑/↓ navigate · Enter select · Esc back     \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json b/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json new file mode 100644 index 00000000000..178f7bb8851 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-cn-startup.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-02T07:26:38.777Z", + "platform": "darwin", + "command": [ + "qoderclicn", + "--config-dir", + "/tmp/orca-qoder-validation-1002/cn-config", + "--cwd", + "/tmp/orca-qoder-validation-1002/workspace" + ], + "cols": 120, + "rows": 40, + "note": "Qoder CLI China 1.1.65; isolated config; no China account", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-cn-startup.txt b/src/main/runtime/__fixtures__/qoder-cn-startup.txt new file mode 100644 index 00000000000..454d7fa8610 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-cn-startup.txt @@ -0,0 +1,65 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI CN | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI CN | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted───────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLI CN v1.1.65│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰──────────────────────────────────────────────────────────────╯ + + + Doyoutrustthefilesinthisfolder? +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLICNcanread,modify,and +executefileshere,andwillloadlocalconfigurations(commands,hooks,MCPservers,agents,skills,settings). + +/private/tmp/orca-qoder-validation-1002/workspace + +❯1.Trustfolder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback +[>0q  ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwillresultinabetter  visual experience.   Do you trust the files in this folder? ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Please confirm this is your own project or from atrustedsource.Oncetrusted,QoderCLICNcanread,modify,and executefileshere,andwillloadlocalconfigurations(commands,hooks,MCPservers,agents,skills,settings).  /private/tmp/orca-qoder-validation-1002/workspace  ❯ 1. Trust folder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json b/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json new file mode 100644 index 00000000000..210126a102c --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/synthetic--qoder-cn.json @@ -0,0 +1,79 @@ +{ + "description": "qoder-cn: title x first-party status on a painted screen; screen x foreground under the titles that leave the low lanes open; dialog order x title. Each read clocked and clockless.", + "cases": { + "title=working-spinner status=none screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=none screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=done-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=done-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=done-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=done-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=working-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=working-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=working-spinner status=blocked-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=working-spinner status=blocked-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=name-only status=none screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=done-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=done-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=done-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=done-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=name-only status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=name-only status=working-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=working-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=name-only status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=name-only status=blocked-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=blocked-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=done-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=done-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=done-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=done-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=none status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=none status=working-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=working-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=blocked-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=none status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=none status=blocked-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=blocked-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=none screen=present fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=present fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=none screen=untrusted fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=untrusted fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=none screen=untrusted fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=untrusted fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=none screen=absent fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=absent fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=none screen=absent fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=none screen=absent fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=present fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=present fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=untrusted fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=untrusted fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=untrusted fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=untrusted fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=absent fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=absent fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=none status=none screen=absent fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=none status=none screen=absent fg=shell clock=clockless": "verdict=pending:closed wait=pending", + "title=working-spinner status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=working-spinner status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=working-spinner status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=working-spinner status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=name-only status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=name-only status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=name-only status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=name-only status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=none status=none screen=dialog-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=none status=none screen=dialog-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=none status=none screen=ready-last fg=agent clock=clocked": "now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "title=none status=none screen=ready-last fg=agent clock=clockless": "verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start" + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json new file mode 100644 index 00000000000..1234da5ab9f --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@agent.json @@ -0,0 +1,23 @@ +{ + "description": "qoder-cn recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-65: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "66-79: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0-23: verdict=pending:closed wait=pending", + "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: verdict=pending:closed wait=pending", + "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-65: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "66-79: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json new file mode 100644 index 00000000000..2950beff30d --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-signin@unknown.json @@ -0,0 +1,25 @@ +{ + "description": "qoder-cn recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "1-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-65: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "66-79: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0: verdict=pending:open wait=ready@poll", + "1-23: verdict=pending:closed wait=pending", + "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: verdict=pending:closed wait=pending", + "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-65: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "66-79: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json new file mode 100644 index 00000000000..73efdc1ed13 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@agent.json @@ -0,0 +1,21 @@ +{ + "description": "qoder-cn recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-66: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll" + ], + "clockless": [ + "0-23: verdict=pending:closed wait=pending", + "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: verdict=pending:closed wait=pending", + "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-66: verdict=pending:closed wait=blocked:agent-trust-workspace@poll" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json new file mode 100644 index 00000000000..ac41c374b38 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--qoder-cn-startup@unknown.json @@ -0,0 +1,23 @@ +{ + "description": "qoder-cn recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "1-23: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "24-36: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "42-45: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: now=blocked:agent-trust-workspace edge=blocked:agent-trust-workspace quiet=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-66: now=pending:closed edge=pending:closed quiet=pending:closed wait=blocked:agent-trust-workspace@poll" + ], + "clockless": [ + "0: verdict=pending:open wait=ready@poll", + "1-23: verdict=pending:closed wait=pending", + "24-36: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "37-41: verdict=pending:closed wait=pending", + "42-45: verdict=pending:closed wait=blocked:agent-trust-workspace@poll", + "46: verdict=blocked:agent-trust-workspace wait=blocked:agent-trust-workspace@start", + "47-66: verdict=pending:closed wait=blocked:agent-trust-workspace@poll" + ] + } +} diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts index 89c5881382a..cc44c70e5ca 100644 --- a/src/main/runtime/qoder-transcripts.test.ts +++ b/src/main/runtime/qoder-transcripts.test.ts @@ -48,3 +48,25 @@ describe('captured Qoder 1.1.64 startup', () => { } ) }) + +describe('captured Qoder China 1.1.65 startup', () => { + it.each(['qoder-cn-startup', 'qoder-cn-signin'])('checks the composer in %s', async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qoderclicn', + launchAgent: 'qoder-cn', + data, + size: { cols: 120, rows: 40 } + }) + expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') + const readiness = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 800 }) + .catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + }) +}) diff --git a/src/main/runtime/readiness-census-synthetic-matrix.ts b/src/main/runtime/readiness-census-synthetic-matrix.ts index 0d555565e27..2273ba080e4 100644 --- a/src/main/runtime/readiness-census-synthetic-matrix.ts +++ b/src/main/runtime/readiness-census-synthetic-matrix.ts @@ -95,6 +95,7 @@ const READY_SCREEN_FIXTURES: Partial< codex: { name: 'codex-0157-plain-ready', cols: 120, rows: 40 }, muse: { name: 'muse-empty-folder-ready', cols: 120, rows: 32 }, qoder: { name: 'qoder-ready', cols: 100, rows: 32 }, + 'qoder-cn': { name: 'qoder-cn-signin', cols: 120, rows: 40 }, cursor: { name: 'cursor-agent-idle-after-approval', cols: 80, rows: 24 } } diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 3ba056ef1e6..3b17119050f 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -44,6 +44,7 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['muse-', { agent: 'muse', foregroundProcess: 'muse' }], ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], + ['qoder-cn-', { agent: 'qoder-cn', foregroundProcess: 'qoderclicn' }], ['qoder-', { agent: 'qoder', foregroundProcess: 'qodercli' }], ['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }] ] diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index a2e6ee3f073..4965092bdc8 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -73,7 +73,7 @@ export function isKnownReadyPromptBody( readScreenLines: () => readonly string[] | null, hasOutputClock: boolean ): boolean { - if (agent === 'qoder') { + if (agent === 'qoder' || agent === 'qoder-cn') { return isQoderComposerReady(readScreenLines()) } // Why before the rules: such an agent settles only on the quiet lane while it has a clock. diff --git a/src/main/runtime/tui-idle-evidence-source.test.ts b/src/main/runtime/tui-idle-evidence-source.test.ts new file mode 100644 index 00000000000..363c1f25499 --- /dev/null +++ b/src/main/runtime/tui-idle-evidence-source.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from './tui-idle-evidence-source' +import { makeTuiIdlePty } from './tui-idle-wait-test-harness' + +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') } +})) + +function source(agent: TuiAgent): TuiIdleEvidenceSource { + return { + quiescenceMs: 3000, + getTabTitle: () => null, + getAdoptedPtyIdleStatus: () => 'idle', + getPaneAgent: () => agent, + getFirstPartyAgentStatus: () => null, + readScreenLines: () => null + } +} + +describe('ptyTuiIdleEvidence', () => { + it.each([ + ['qoder', 'qoder-trust-dialog'], + ['qoder-cn', 'qoder-cn-signin'] + ] as const)('requires a live composer for adopted %s panes', (agent, fixture) => { + const transcript = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + const evidence = ptyTuiIdleEvidence(source(agent), makeTuiIdlePty(), () => transcript) + expect(evidence.readPositiveBodyEvidence()).toBe(false) + }) + + it('keeps adopted idle evidence for other agents', () => { + const evidence = ptyTuiIdleEvidence(source('claude'), makeTuiIdlePty(), () => '') + expect(evidence.readPositiveBodyEvidence()).toBe(true) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts index 3a1b5277999..4340dd00f21 100644 --- a/src/main/runtime/tui-idle-evidence-source.ts +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -134,7 +134,9 @@ export function ptyTuiIdleEvidence( record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => - (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || + (agent !== 'qoder' && + agent !== 'qoder-cn' && + source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 2c28f335232..68f472a6339 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -274,7 +274,7 @@ function rankTuiIdleEvidence(input: TuiIdleEvaluationInput): TuiIdleVerdict { 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 (input.agent === 'qoder' || input.agent === 'qoder-cn') { if ( hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) || input.record.lastAgentStatus === 'working' diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts index 273c1449329..d8afda34524 100644 --- a/src/main/workspace-trust-test-fixtures.ts +++ b/src/main/workspace-trust-test-fixtures.ts @@ -15,6 +15,8 @@ export function workspaceTrustWritten(home: string, preset: AgentTrustPreset): b } case 'copilot': return existsSync(join(home, '.copilot', 'config.json')) + case 'qoder-cn': + return existsSync(join(home, '.qoder-cn', 'settings.json')) case 'qoder': return existsSync(join(home, '.qoder', 'settings.json')) case 'antigravity': diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts index d7f20d2961c..e17998e102e 100644 --- a/src/relay/agent-workspace-trust-spawn.test.ts +++ b/src/relay/agent-workspace-trust-spawn.test.ts @@ -35,10 +35,11 @@ const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [ ['codex', 'codex'], ['cursor', 'cursor'], ['copilot', 'copilot'], - ['qoder', 'qoder'] + ['qoder', 'qoder'], + ['qoder-cn', 'qoder-cn'] ] const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) => - ['claude', 'copilot', 'qoder'].includes(preset) + ['claude', 'copilot', 'qoder', 'qoder-cn'].includes(preset) ) const ALL_PRESETS: AgentTrustPreset[] = [ 'claude', @@ -46,6 +47,7 @@ const ALL_PRESETS: AgentTrustPreset[] = [ 'cursor', 'copilot', 'qoder', + 'qoder-cn', 'antigravity' ] diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 629aea0ee15..2ba0a0e2144 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -199,9 +199,11 @@ describe('AgentCombobox', () => { it('renders bundled favicons for favicon-domain agents instead of the remote Google service', () => { // Why: previously loaded from Google's favicon service (#8451). Iterate the // full asset map so missing files/key mismatches fail the test. + const sharedIcons = new Map([['qoder-cn', 'qoder']]) for (const agent of Object.keys(AGENT_FAVICON_ASSETS) as TuiAgent[]) { const markup = renderToStaticMarkup() - expect(markup).toContain(`/shared/agent-icons/${agent}.png`) + const iconName = sharedIcons.get(agent) ?? agent + expect(markup).toContain(`/shared/agent-icons/${iconName}.png`) expect(markup).not.toContain('https://www.google.com/s2/favicons') } }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 88943327a37..a187c118eeb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -644,7 +644,8 @@ "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", "opencode2_label": "OpenCode 2", - "codebuddy_label": "CodeBuddy" + "codebuddy_label": "CodeBuddy", + "qoder_cn_label": "Qoder CLI China" }, "skill": { "cli": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 325d838552d..bef0244dd39 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -128,6 +128,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'qoder.com', homepageUrl: 'https://docs.qoder.com/cli/overview' }, + { + id: 'qoder-cn', + label: translate('auto.lib.agent.catalog.qoder_cn_label', 'Qoder CLI China'), + cmd: 'qoderclicn', + faviconDomain: 'qoder.cn', + homepageUrl: 'https://docs.qoder.cn/cli/overview' + }, { id: 'zcode', label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'), @@ -391,10 +398,7 @@ export function AgentIcon({ if (agent === 'copilot') { return } - if (agent === 'opencode') { - return - } - if (agent === 'opencode2') { + if (agent === 'opencode' || agent === 'opencode2') { return } const catalogEntry = getAgentCatalog().find((a) => a.id === agent) diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index b041965736e..226d4727380 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -44,6 +44,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { trae: traeUrl, 'prime-agent': primeAgentUrl, qoder: qoderUrl, + 'qoder-cn': qoderUrl, gemini: geminiUrl, antigravity: antigravityUrl, goose: gooseUrl, diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index caff18e5c67..c6f02089c0c 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -66,12 +66,43 @@ describe('agent picker search', () => { expect(replaceSpy).not.toHaveBeenCalled() }) - it('resolves every catalog command alias to its agent first', () => { + it('resolves catalog commands to the first entry owning that command', () => { for (const agent of AGENT_CATALOG) { - expect(searchAgentPickerEntries(AGENT_CATALOG, agent.cmd)[0]?.id).toBe(agent.id) + const owner = AGENT_CATALOG.find((candidate) => candidate.cmd === agent.cmd) + expect(searchAgentPickerEntries(AGENT_CATALOG, agent.cmd)[0]?.id).toBe(owner?.id) } }) + it.each([ + ['qoder', 'qoder'], + ['qodercli', 'qoder'], + ['qoder-cn', 'qoder-cn'], + ['qoderclicn', 'qoder-cn'], + ['Qoder CLI China', 'qoder-cn'], + ['qwen', 'qwen-code'] + ] as const)('resolves explicit identity or command %s to %s', (query, expected) => { + expect(searchAgentPickerEntries(AGENT_CATALOG, query)[0]?.id).toBe(expected) + }) + + it('keeps a stable default for a shared binary while explicit CN remains selectable', () => { + const shared = [ + entry('qoder', 'Qoder CLI', 'qodercli'), + entry('qoder-cn', 'Qoder CLI China', 'qodercli') + ] + expect(searchAgentPickerEntries(shared, 'qodercli').map((agent) => agent.id)).toEqual([ + 'qoder', + 'qoder-cn' + ]) + expect(searchAgentPickerEntries(shared, 'qoder-cn')[0]?.id).toBe('qoder-cn') + expect(searchAgentPickerEntries(shared, 'Qoder CLI China')[0]?.id).toBe('qoder-cn') + expect( + searchAgentPickerEntries( + shared.filter((agent) => agent.id !== 'qoder'), + 'qodercli' + )[0]?.id + ).toBe('qoder-cn') + }) + it('returns no entries for unrelated text', () => { expect(searchAgentPickerEntries(agents, 'not-an-agent')).toEqual([]) }) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index 24ee2896742..b3129feb8cc 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -86,20 +86,25 @@ export function agentPickerBlankTerminalMatches(rawQuery: string): boolean { function scoreAgent(agent: AgentCatalogEntry, query: string): number { return Math.min( scoreCandidate(query, agent.label, 0), - scoreCandidate(query, agent.id, 600), - scoreCandidate(query, agent.cmd, 650), - ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650)) + scoreCandidate(query, agent.id, 600, 1), + scoreCandidate(query, agent.cmd, 650, 2), + ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650, 2)) ) } -function scoreCandidate(query: string, rawCandidate: string, baseScore: number): number { +function scoreCandidate( + query: string, + rawCandidate: string, + baseScore: number, + exactScore = baseScore +): number { const candidate = normalizeSearchText(rawCandidate) if (!candidate) { return NO_MATCH } if (candidate === query) { - return baseScore + return exactScore } if (candidate.startsWith(query)) { return baseScore + 10 diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 04ecd728541..f28277cc3c9 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -111,6 +111,7 @@ const ICONABLE_AGENT_TYPES: Record = { omp: true, 'prime-agent': true, qoder: true, + 'qoder-cn': 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 fc8a80f1c9c..861157eb6a8 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,8 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, @@ -53,6 +55,8 @@ describe('agentResumeHostAuthorityCapability', () => { codex: undefined, cursor: AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + 'qoder-cn': AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, + 'qwen-code': AGENT_SESSION_QWEN_CODE_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 4431f30b220..a296d4ef2d7 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -4,6 +4,8 @@ import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, @@ -26,6 +28,8 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { codex: undefined, cursor: AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + 'qoder-cn': AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, + 'qwen-code': AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY, gemini: undefined, antigravity: undefined, opencode: 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 99d09068720..b126f162c85 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', 'codebuddy', 'cursor'] as const)( + it.each(['kimi', 'muse', 'codebuddy', 'qoder', 'qoder-cn', 'qwen-code', 'cursor'] as const)( 'falls back to legacy when an older host lacks the %s resume capability', async (agent) => { const hostAuthority = vi.fn().mockResolvedValue('structured') @@ -70,7 +70,7 @@ describe('remote agent-session launch routing', () => { } ) - it.each(['muse', 'codebuddy'] as const)( + it.each(['muse', 'codebuddy', 'qoder', 'qoder-cn', 'qwen-code'] as const)( 'uses host authority when the host supports %s resume', async (agent) => { const hostAuthority = vi.fn().mockResolvedValue('host') diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 9f742a5355d..b158cf86898 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -18,6 +18,7 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { qoder: isQoderHeadlessCommand, + 'qoder-cn': isQoderHeadlessCommand, claude: isPrintModeHeadlessOneShotCommand, codebuddy: isCodebuddyNonInteractiveCommand, trae: isPrintModeHeadlessOneShotCommand, diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index b7fffa09f16..77f24b6eefc 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -163,6 +163,21 @@ export function normalizeProviderEvent(input: { case 'devin': payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'qoder-cn': + payload = normalizeCompatibleLifecycleEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayload + ) + break + case 'qwen-code': { + const normalized = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) + payload = normalized ? { ...normalized, agentType: 'qwen-code' } : null + break + } case 'qoder': payload = normalizeQoderEvent(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 80cd0fdc91c..eee05a64695 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -25,6 +25,8 @@ 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-cn': + case 'qwen-code': case 'qoder': case 'claude': // Why: SessionStart lands an idle row (STA-3386) and must also drop stale @@ -140,6 +142,8 @@ 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-cn': + case 'qwen-code': case 'qoder': case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. diff --git a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts index 2678dfc5e1e..ba7362b824a 100644 --- a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts @@ -48,7 +48,7 @@ function readCompatibleState( } export function normalizeCompatibleLifecycleEvent( - source: 'qoder' | 'codebuddy', + source: 'qoder' | 'qoder-cn' | 'codebuddy', state: HookListenerState, eventName: unknown, promptText: string, diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index 2940685665c..7f4b4c39356 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -6,6 +6,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/claude': 'claude', '/hook/codex': 'codex', '/hook/qoder': 'qoder', + '/hook/qoder-cn': 'qoder-cn', + '/hook/qwen-code': 'qwen-code', '/hook/codebuddy': 'codebuddy', '/hook/gemini': 'gemini', '/hook/antigravity': 'antigravity', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 6da6f2b2df0..9289bde7381 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -39,6 +39,8 @@ const AGENT_HOOK_SOURCES = [ 'claude', 'codex', 'qoder', + 'qoder-cn', + 'qwen-code', 'codebuddy', 'gemini', 'antigravity', diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 0bc5b67bc28..191016259ac 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -8,6 +8,8 @@ export const AGENT_HOOK_TARGETS = [ 'openclaude', 'codex', 'qoder', + 'qoder-cn', + 'qwen-code', 'codebuddy', 'gemini', 'antigravity', diff --git a/src/shared/agent-icons/qoder-cn.png b/src/shared/agent-icons/qoder-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..42c952ed33a1240c57832c8d71c093f59e76fa65 GIT binary patch literal 2416 zcmYM0c{J4j8pppAGZ z9|C|BKY;sh#%A9Sp2fZnX8z|~#h}0G{a*2*e`v#EuK(wQu`qgg%D(XtEv|$B0Ke#g zKtOK(vHe}rElrFZB0*DE`L5%7#rJ&ZSC?}p`|J=nUO6O)+>O`9C@X=CbcRM`J}dNn z1%pmw$BgakE3p?pnW=z~H+$rhK0cK>K~f?(%fNjdgneFN-1EvH2yp*d#FqtqdR`1C z_eY?1%R=MkTN);ox%EX`J!p@^@&2MwNN?u$-TGx|3qrWZVyEE05)f#`oTi zln#!zoZFx{!!a#MQu~AZQBDoAq8b@If+`?OS7{+!m7fA@d(y$MznzlbzTYfD5xbz*U z=dar1MSOSQt^+DjM~ADc$H3gSq&a^UrM7%N0~vL4Y$BzN+a0Fz!}rA+;8uVN%YaKu zfJ(DV94--Ls_bSI#r)Yb|E?8l-V?^vK3$`l$FHhgR|zq@n6rWO8V6^=R@)$LRGy=z zG~_&Ud#8wDXjkv6wCK-kzfva--?PzYN%#+HpEOZhl)dJLmJ6ue>{q7iJzYdDcwLeo z-o1M%AdzxMlFOM~Mg;o@PSuD>#}z!RRdvdQz&=kskU zWRTpnlnl!`(EzJ~`l4ZSaEiUcgt{mYPHBC0CPeI0j`t^)hkD>`h<|my`Pa1}N2a3! z8V~EZ4@y`})GWplF8yFXk$$&@R{(5_KWMU`1S?yk_q!olE}z6$^bw&i-Gepbg=Iu3 zDNl>q*~5&Q6Hoa>4KFfHzaenyq5ukb`K44+ThO@L{{N-Pns z^!m}h14N`+ov9Peim|)QC}*?>7iwmmnnER(z7o}fG>{_fZQn)VVinE;sI@tyCDjO} zM^{DL$3>^W&v~V8_-sxghO)YWm2*>#>Y|!^fwFnkNv+pjdQ~Nwnhl`6JR^NgD^5Y& zq#i}bxri`-#)u{=gz|Y8#j$3dSks-<&h;RG!+0x-tdCwvR3Hmh5PWI03sSFB+CKB> zsqE#9EX@0~3Pp)%uE=`|Q5>=EY8kif`^1J!C72hxCu(G!E&Kfq)V^)hT%16ewkxsD zpU*BvY{cBE+c-N~FJIp6EB~kNA((pQMi-p6JoqvvT-{qIM>Px`@ zqmJBJxD(&^^MQ1xhSp4$w~hQUs@XkhVaArx%7z>LjSdv2a{O!nCrmSScYMm}JNvNt z17rC*OoZs?`!udME58}iH@DWT`382zqkS3`geoIEvsXhF2cY-`N@KGRSI2Z4#03&_ z&$Yq?>i|CP?>M_@mMPhtQ5t+a=Ie7DKvZ}+^Dsj>Het1y+!|VA@Cy85JKr^g;(8~S z1u%4zmAyEr1g_yXkDCMy;RcwJUe*I1LD$BNzfdur>;ZJL1^QZGwR9V!X?I{%&L$8i>E9oL_(0xa%rC4lrGO`0Gd)`C zw-$VJml5nI{bqn2u}R|ht0EPz_YoHcC`W{V*x`sUo=gtbGOS!&i^?OM_p4y8_uQ0u zD*b%ofU139FS{_;5jdflbwkn+XJIrOjB}tUO7MoqC*(vmVs-V$^FRKY%C`U6kSY2cb0QuT$MYUk@cTh8z*Pd9z>7 z0uolxqX(UfQqiP~l!H6cCsOuM1byR=&)Ux+q;Vn_gHk@a@XX`tCH=B;^?Pu#$I< z6s|Cae^WXoLVEi2`=jAc-YG5$?1l*u2bNcrTR*-cC%^5Z8px4Taw9l05MR-Y4QKJRAa6y;i z(QmcRJ?lJq=tu}@J0W)Gb>`E(pwR~r6kSfLRR8;0fWaF?aD|D1ewnqpCVPFv agentType: 'qoder' }) }) + +it.each(['qoder-cn', 'qwen-code'] as const)('keeps %s lifecycle and resume attributed', (agent) => { + const state = createHookListenerState() + const send = (hook_event_name: string, extra = {}) => + normalizeAndAccept(state, agent, { + hook_event_name, + session_id: 'provider-proof', + ...extra + }) + expect(send('SessionStart', { source: 'startup' })?.payload).toMatchObject({ + agentType: agent, + state: 'done', + sessionBoundary: true + }) + expect(send('UserPromptSubmit', { prompt: 'Create proof.txt' })?.payload).toMatchObject({ + agentType: agent, + state: 'working' + }) + expect(send('PermissionRequest', { tool_name: 'Write' })?.payload).toMatchObject({ + agentType: agent, + state: 'waiting' + }) + expect(send('Stop', { last_assistant_message: 'Done' })?.payload).toMatchObject({ + agentType: agent, + state: 'done' + }) + expect(send('SessionStart', { source: 'resume' })?.providerSession?.id).toBe('provider-proof') + expect(getAgentResumeArgv(agent, { key: 'session_id', id: 'provider-proof' })).toEqual([ + agent === 'qoder-cn' ? 'qoderclicn' : 'qwen', + '--resume', + 'provider-proof' + ]) +}) + +it.each([ + 'qoderclicn', + 'qodercn', + '/home/test/.qoder-cn/bin/qoderclicn-1.1.65', + 'C:\\Qoder\\qoderclicn.exe' +])('recognizes the China executable %s', (command) => { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder-cn') + expect(recognizeAgentProcessFromCommandLine(`${command} --print hello`)).toBeNull() +}) diff --git a/src/shared/qoder-cn-launch-recipe.test.ts b/src/shared/qoder-cn-launch-recipe.test.ts new file mode 100644 index 00000000000..510d2d94077 --- /dev/null +++ b/src/shared/qoder-cn-launch-recipe.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' + +describe('Qoder China launch recipe identity', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'preserves the official China executable on %s', + (platform) => { + const plan = buildAgentStartupPlan({ + agent: 'qoder-cn', + prompt: 'fixture', + cmdOverrides: {}, + platform + }) + expect(plan?.agent).toBe('qoder-cn') + expect(plan?.launchCommand).toBe("qoderclicn --prompt-interactive 'fixture'") + expect( + buildAgentResumeStartupPlan({ + agent: 'qoder-cn', + providerSession: { key: 'session_id', id: 'fixture-session' }, + cmdOverrides: {}, + platform + })?.launchCommand + ).toBe("qoderclicn '--resume' 'fixture-session'") + } + ) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'preserves an explicitly configured shared-binary --cn recipe on %s', + (platform) => { + const cmdOverrides = { 'qoder-cn': 'qodercli --cn' } + const plan = buildAgentStartupPlan({ + agent: 'qoder-cn', + prompt: 'fixture', + cmdOverrides, + platform + }) + expect(plan?.agent).toBe('qoder-cn') + expect(plan?.launchCommand).toBe("qodercli --cn --prompt-interactive 'fixture'") + expect( + buildAgentResumeStartupPlan({ + agent: 'qoder-cn', + providerSession: { key: 'session_id', id: 'fixture-session' }, + cmdOverrides, + platform + })?.launchCommand + ).toBe("qodercli --cn '--resume' 'fixture-session'") + } + ) +}) diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 1955bfa6d58..0c3624ad2a8 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -26,6 +26,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { omp: null, 'prime-agent': null, qoder: 'qoder', + 'qoder-cn': 'qoder-cn', gemini: 'gemini-cli', antigravity: 'antigravity', aider: null, diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index a45a2f8d9f2..b8ba933f518 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -21,6 +21,7 @@ export const AGENT_KIND_VALUES = [ 'omp', 'prime-agent', 'qoder', + 'qoder-cn', 'gemini', 'antigravity', 'aider', diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 045b0aeceec..65ba82e83d0 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -175,7 +175,7 @@ function computeAgentLabel(title: string): string | null { return 'DeepSeek Harness' } if (isQoderTerminalTitle(title)) { - return 'Qoder CLI' + return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI' } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' @@ -258,6 +258,8 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OpenClaude: 'openclaude', Codex: 'codex', 'Qoder CLI': 'qoder', + 'Qoder CLI CN': 'qoder-cn', + 'Qoder CLI China': 'qoder-cn', 'Gemini CLI': 'gemini', 'GitHub Copilot': 'copilot', Grok: 'grok', diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b..0729079d093 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -38,7 +38,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 /** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */ - preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' + preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn' /** 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. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a7be259df2c..3697079c91e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -146,6 +146,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'flag-prompt-interactive', preflightTrust: 'qoder' }, + 'qoder-cn': { + detectCmd: 'qoderclicn', + detectCmdAliases: ['qodercn'], + promptInjectionMode: 'flag-prompt-interactive', + preflightTrust: 'qoder-cn' + }, 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 31b6035a953..10624436813 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -25,6 +25,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { omp: 'OMP', 'prime-agent': 'Prime Agent', qoder: 'Qoder CLI', + 'qoder-cn': 'Qoder CLI China', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 9fc8eb0a691..db014311459 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -10,6 +10,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { openclaude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', qoder: '--dangerously-skip-permissions', + 'qoder-cn': '--dangerously-skip-permissions', gemini: '--yolo', antigravity: '--dangerously-skip-permissions', aider: '--yes-always', diff --git a/src/shared/tui-agent-rest-signal.test.ts b/src/shared/tui-agent-rest-signal.test.ts index 6e573acc0b0..ad375e657c6 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', + 'qoder-cn': 'ready-body', codebuddy: 'none', autohand: 'none', ante: 'none', diff --git a/src/shared/tui-agent-rest-signal.ts b/src/shared/tui-agent-rest-signal.ts index 93f694a5d79..b1579711378 100644 --- a/src/shared/tui-agent-rest-signal.ts +++ b/src/shared/tui-agent-rest-signal.ts @@ -20,7 +20,8 @@ export type TuiAgentRestSignal = 'hook-done' | 'synthetic-title' | 'title' | 're const IDENTITY_REST_SIGNALS: Partial> = { dsh: 'hook-done', muse: 'ready-body', - qoder: 'ready-body' + qoder: 'ready-body', + 'qoder-cn': 'ready-body' } // Why derived, not declared per agent: the title tables are the evidence, so a second diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index acf305dad59..21390d9adfd 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -18,6 +18,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'muse', 'dsh', 'qoder', + 'qoder-cn', 'zcode', 'pi', 'omp', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index a98c45101fd..281b7c5b60d 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -13,6 +13,7 @@ export type TuiAgent = | 'pi' // Pi (pi.dev) | 'omp' // OMP (omp.sh) | 'qoder' // Qoder CLI + | 'qoder-cn' // Qoder CLI China | 'gemini' // Gemini CLI | 'antigravity' // Google Antigravity CLI | 'aider' // Aider diff --git a/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts new file mode 100644 index 00000000000..6d432c7c644 --- /dev/null +++ b/tests/e2e/cross-version-wire/qoder-registration-downgrade.unit.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest' +import { EnsureAgentSessionParams } from '../../../src/shared/rpc-contract/agent-session-params' +import { normalizeAgentStatusPayload } from '../../../src/shared/agent-status-types' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +test.each(['v1.4.205', 'v1.4.211'])( + 'release %s preserves new provider status tags but refuses unsupported resume', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const baselineStatus = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-status-types.ts' + ) + const normalize = baselineStatus.normalizeAgentStatusPayload + if (typeof normalize !== 'function') { + throw new Error('Pinned release has no status normalizer') + } + const baselineResume = await importReleaseCheckoutModule( + checkout, + 'src/shared/rpc-contract/agent-session-params.ts' + ) + const schema = baselineResume.EnsureAgentSessionParams + if ( + !schema || + typeof schema !== 'object' || + !('safeParse' in schema) || + typeof schema.safeParse !== 'function' + ) { + throw new Error('Pinned release has no resume request parser') + } + for (const agent of ['qoder-cn', 'qwen-code'] as const) { + const status = { state: 'working', agentType: agent } + expect(normalize(status)).toMatchObject(status) + expect(normalizeAgentStatusPayload(status)).toMatchObject(status) + const request = { + kind: 'explicit', + worktree: 'folder:/task-owned/folder', + agent, + providerSession: { key: 'session_id', id: 'test-session' } + } + expect(EnsureAgentSessionParams.safeParse(request).success).toBe(true) + expect(schema.safeParse(request)).toHaveProperty('success', false) + } + }, + 120_000 +)