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 <orca-validation@invalid.example>
This commit is contained in:
Neil
2026-10-02 22:13:26 -07:00
committed by GitHub
co-authored by Orca Integration Recovery
parent 08ee7ba9ef
commit 843607b1bc
73 changed files with 903 additions and 63 deletions
+1
View File
@@ -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",
@@ -20,6 +20,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
'prime-agent': require('../../../src/shared/agent-icons/prime-agent.png'),
codebuddy: require('../../../src/shared/agent-icons/codebuddy.png'),
qoder: require('../../../src/shared/agent-icons/qoder.png'),
'qoder-cn': require('../../../src/shared/agent-icons/qoder-cn.png'),
gemini: require('../../../src/shared/agent-icons/gemini.png'),
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
goose: require('../../../src/shared/agent-icons/goose.png'),
+1
View File
@@ -28,6 +28,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
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',
@@ -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()],
@@ -92,6 +92,34 @@ const buildersByAgent = new Map<string, CommandBuilders>([
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', '')
@@ -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]
@@ -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)],
@@ -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<AgentHookSource, string | null> = {
qoder: 'SessionStart',
'qoder-cn': 'SessionStart',
'qwen-code': 'SessionStart',
claude: 'SessionStart',
kimi: 'UserPromptSubmit',
codebuddy: 'UserPromptSubmit',
@@ -47,6 +47,7 @@ describe('applyAgentWorkspaceTrustToSpawn', () => {
'cursor',
'copilot',
'qoder',
'qoder-cn',
'antigravity'
])
)
+8 -2
View File
@@ -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'
}
+6 -4
View File
@@ -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
@@ -52,6 +52,7 @@ export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record<AgentTrustPreset, boolean>
cursor: false,
copilot: true,
qoder: true,
'qoder-cn': true,
antigravity: false
}
@@ -66,6 +67,7 @@ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record<AgentTrustPreset, boolean
cursor: false,
copilot: false,
qoder: false,
'qoder-cn': false,
antigravity: false
}
@@ -106,6 +108,8 @@ async function writePreset(
return markCursorWorkspaceTrusted(storedPath, host.agentHome)
case 'copilot':
return markCopilotFolderTrusted(storedPath, host.agentHome)
case 'qoder-cn':
return markQoderWorkspaceTrusted(storedPath, host.agentHome, '.qoder-cn')
case 'qoder':
return markQoderWorkspaceTrusted(storedPath, host.agentHome)
case 'antigravity':
+51 -1
View File
@@ -11,8 +11,9 @@ vi.mock('node:os', async (importOriginal) => ({
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] }
})
}
)
+13
View File
@@ -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
})
+6 -2
View File
@@ -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
+37
View File
@@ -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
})
@@ -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
}
@@ -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␍␍␍␍
@@ -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
}
@@ -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␍

@@ -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"
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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)
})
})
@@ -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 }
}
@@ -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' }]
]
+1 -1
View File
@@ -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.
@@ -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)
})
})
+3 -1
View File
@@ -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),
+1 -1
View File
@@ -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'
@@ -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':
@@ -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'
]
@@ -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(<AgentIcon agent={agent} />)
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')
}
})
+2 -1
View File
@@ -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": {
+8 -4
View File
@@ -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 <CopilotIcon size={size} />
}
if (agent === 'opencode') {
return <OpenCodeIcon size={size} />
}
if (agent === 'opencode2') {
if (agent === 'opencode' || agent === 'opencode2') {
return <OpenCodeIcon size={size} />
}
const catalogEntry = getAgentCatalog().find((a) => a.id === agent)
@@ -44,6 +44,7 @@ export const AGENT_FAVICON_ASSETS: Partial<Record<TuiAgent, string>> = {
trae: traeUrl,
'prime-agent': primeAgentUrl,
qoder: qoderUrl,
'qoder-cn': qoderUrl,
gemini: geminiUrl,
antigravity: antigravityUrl,
goose: gooseUrl,
@@ -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([])
})
+10 -5
View File
@@ -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
+1
View File
@@ -111,6 +111,7 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
omp: true,
'prime-agent': true,
qoder: true,
'qoder-cn': true,
gemini: true,
antigravity: true,
aider: true,
@@ -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,
@@ -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,
@@ -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')
+1
View File
@@ -18,6 +18,7 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial<
Record<TuiAgent, (tokens: readonly string[]) => boolean>
> = {
qoder: isQoderHeadlessCommand,
'qoder-cn': isQoderHeadlessCommand,
claude: isPrintModeHeadlessOneShotCommand,
codebuddy: isCodebuddyNonInteractiveCommand,
trae: isPrintModeHeadlessOneShotCommand,
@@ -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
@@ -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.
@@ -48,7 +48,7 @@ function readCompatibleState(
}
export function normalizeCompatibleLifecycleEvent(
source: 'qoder' | 'codebuddy',
source: 'qoder' | 'qoder-cn' | 'codebuddy',
state: HookListenerState,
eventName: unknown,
promptText: string,
@@ -6,6 +6,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/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',
+2
View File
@@ -39,6 +39,8 @@ const AGENT_HOOK_SOURCES = [
'claude',
'codex',
'qoder',
'qoder-cn',
'qwen-code',
'codebuddy',
'gemini',
'antigravity',
+2
View File
@@ -8,6 +8,8 @@ export const AGENT_HOOK_TARGETS = [
'openclaude',
'codex',
'qoder',
'qoder-cn',
'qwen-code',
'codebuddy',
'gemini',
'antigravity',
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -27,6 +27,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
omp: 'omp',
'prime-agent': 'prime-agent',
qoder: 'qoder',
'qoder-cn': 'qoder-cn',
gemini: 'gemini',
antigravity: 'antigravity',
aider: 'aider',
+5
View File
@@ -74,6 +74,9 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
return PROCESS_TO_AGENT.get('codex')
}
// Qoder's launcher resolves to a versioned native binary.
if (/^(?:qoderclicn|qodercn)-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qoderclicn')
}
if (/^qodercli-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qodercli')
}
@@ -162,6 +165,8 @@ export function isExpectedAgentProcess(
return (
normalizedProcess === normalizedExpected ||
normalizedProcess.startsWith(`${normalizedExpected}.`) ||
(['qoderclicn', 'qodercn'].includes(normalizedExpected) &&
/^(?:qoderclicn|qodercn)(?:-\d.*)?$/.test(normalizedProcess)) ||
(normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) ||
(normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-'))
)
@@ -0,0 +1,33 @@
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
// older host answers the unknown member with invalid_argument — a code the launch fallback does
// not retry on — so clients must probe before taking the host-authority path.
export const AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY =
'agent-session.cursor-resume.v1' as const
export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const
export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
'agent-session.opencode2-resume.v1' as const
export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY =
'agent-session.codebuddy-resume.v1' as const
export const AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY =
'agent-session.qoder-cn-resume.v1' as const
export const AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY =
'agent-session.qwen-code-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
export const AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES = [
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
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_QODER_CN_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY
] as const
+8
View File
@@ -8,6 +8,8 @@ export const RESUMABLE_TUI_AGENTS = [
'codebuddy',
'codex',
'qoder',
'qoder-cn',
'qwen-code',
'gemini',
'antigravity',
'opencode',
@@ -197,6 +199,8 @@ 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-cn':
case 'qwen-code':
case 'qoder':
case 'codebuddy':
case 'claude':
@@ -291,6 +295,10 @@ export function getAgentResumeArgv(
return providerSession.key === 'conversation_id' ? ['cursor-agent', '--resume', id] : null
case 'codex':
return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null
case 'qoder-cn':
return providerSession.key === 'session_id' ? ['qoderclicn', '--resume', id] : null
case 'qwen-code':
return providerSession.key === 'session_id' ? ['qwen', '--resume', id] : null
case 'qoder':
return providerSession.key === 'session_id' ? ['qodercli', '--resume', id] : null
case 'gemini':
+1 -1
View File
@@ -74,7 +74,7 @@ function computeAgentLabel(title: string): string | null {
return piStateBrand
}
if (isQoderTerminalTitle(title)) {
return 'Qoder CLI'
return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
+2 -1
View File
@@ -132,7 +132,8 @@ export function createAgentStatusTracker(
export function normalizeTerminalTitle(title: string): string {
const qoderStatus = qoderTitleStatus(title)
if (qoderStatus) {
return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} Qoder CLI`
const label = title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI'
return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} ${label}`
}
if (!title) {
return title
+14 -24
View File
@@ -1,3 +1,16 @@
import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities'
export {
AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY
} from './agent-session-resume-runtime-capabilities'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities'
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
@@ -246,22 +259,6 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY =
// stop capability above, which a client can advertise while predating this.
export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY =
'agent-session.background-task-row-stop.v1' as const
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
// older host answers the unknown member with invalid_argument — a code the launch fallback does
// not retry on — so clients must probe before taking the host-authority path.
export const AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY =
'agent-session.cursor-resume.v1' as const
export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const
export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
'agent-session.opencode2-resume.v1' as const
export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY =
'agent-session.codebuddy-resume.v1' as const
export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY =
'agent-session.qoder-resume.v1' as const
export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY =
'agent-session.zcode-resume.v1' as const
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
@@ -414,14 +411,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
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_CODEBUDDY_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
...AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY,
+43
View File
@@ -158,3 +158,46 @@ it('settles manual compaction without interrupting automatic compaction', () =>
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()
})
+49
View File
@@ -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'")
}
)
})
+1
View File
@@ -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,
+1
View File
@@ -21,6 +21,7 @@ export const AGENT_KIND_VALUES = [
'omp',
'prime-agent',
'qoder',
'qoder-cn',
'gemini',
'antigravity',
'aider',
+3 -1
View File
@@ -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<Record<string, TuiAgent>> = {
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',
+1 -1
View File
@@ -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. */
+6
View File
@@ -146,6 +146,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
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'
+1
View File
@@ -25,6 +25,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record<TuiAgent, string> = {
omp: 'OMP',
'prime-agent': 'Prime Agent',
qoder: 'Qoder CLI',
'qoder-cn': 'Qoder CLI China',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
+1
View File
@@ -10,6 +10,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
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',
+1
View File
@@ -29,6 +29,7 @@ const EXPECTED_REST_SIGNALS: Record<TuiAgent, TuiAgentRestSignal> = {
grok: 'title',
muse: 'ready-body',
qoder: 'ready-body',
'qoder-cn': 'ready-body',
codebuddy: 'none',
autohand: 'none',
ante: 'none',
+2 -1
View File
@@ -20,7 +20,8 @@ export type TuiAgentRestSignal = 'hook-done' | 'synthetic-title' | 'title' | 're
const IDENTITY_REST_SIGNALS: Partial<Record<TuiAgent, TuiAgentRestSignal>> = {
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
+1
View File
@@ -18,6 +18,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'muse',
'dsh',
'qoder',
'qoder-cn',
'zcode',
'pi',
'omp',
+1
View File
@@ -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
@@ -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
)