feat: add first-class Qoder CLI support (#23581)

feat: add first-class Qoder CLI support

Integrate Qoder launch, identity, canonical hook status, trust and resume.
Verify with captured Qoder 1.1.64 transcripts and hidden Electron sidebar checks.

Builds on and cross-reviews #7502, #8611, #9655, #12910, #13311 and #15291.

Co-authored-by: dalveytech-vincent <vincent@dalveytech.com>
Co-authored-by: Eridanus117 <45489268+Eridanus117@users.noreply.github.com>
Co-authored-by: xingqingzzp-gif <xingqingzzp-gif@users.noreply.github.com>
Co-authored-by: jyang2004 <jyang2004@users.noreply.github.com>
Co-authored-by: yunqian <yunqian@alibaba-inc.com>
Co-authored-by: huzhening.hzn <huzhening.hzn@alibaba-inc.com>
This commit is contained in:
Neil
2026-09-28 02:59:50 -07:00
committed by GitHub
co-authored by dalveytech-vincent Eridanus117 xingqingzzp-gif jyang2004 yunqian huzhening.hzn
parent 08300512eb
commit 8b410b4893
73 changed files with 1179 additions and 47 deletions
+1
View File
@@ -6,6 +6,7 @@
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/grok-replay-guard.ts",
"../src/main/claude/hook-script.ts",
"../src/main/qoder/hook-service.ts",
"../src/main/claude/claude-session-end-hook-capability.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
+89
View File
@@ -0,0 +1,89 @@
# Qoder CLI integration
Orca registers `qodercli` as `qoder`: detection, picker/settings, desktop and mobile
identity, prompt launch, permission flags, managed hooks, workspace trust, and
session resume use the existing TUI-agent and hook-status paths.
## Verified contract
Verified on macOS with Qoder CLI 1.1.64, including its versioned executable
`qodercli-1.1.64`:
- `--prompt-interactive` starts an interactive prompt; `--resume <id>` resumes a
session; `--dangerously-skip-permissions` is the permission bypass flag.
- Hooks use the Claude-shaped nested configuration in `~/.qoder/settings.json`.
Orca registers its own `/hook/qoder` source and preserves user hooks.
- Real SessionStart, UserPromptSubmit, Notification and SessionEnd events are
captured in `src/shared/__fixtures__/qoder-no-account-hooks.jsonl`, including
`source: resume` with the original session ID.
- Trust is `permissions.trustDirectories` in that settings file, using the
canonical workspace path. A workspace `.trusted` marker did not bypass the
trust dialog in this version. Remote trust uses the execution host filesystem.
- Qoder emits its `◇ … | Ready` title while the trust menu owns input. Readiness
therefore requires the live composer text, not merely this title or silence.
Raw PTY fixtures under `src/main/runtime/__fixtures__/qoder-*` cover trust,
unauthenticated prompt handling and the ready composer.
- A hidden Orca dev instance launched Qoder from New Tab in a folder workspace.
Its icon, label, terminal rendering and failed-turn indicator were inspected.
A harmless prompt reached Qoder, which reported its credit usage limit; the
canonical hook store recorded Qoder identity, session metadata and failure.
## Compatibility and limits
Windows hooks explicitly select Qoder's documented PowerShell shell, avoiding
an assumption that Qoder uses Git Bash just because it is installed. They reuse
the existing managed `.cmd` payload without adding an encoded PowerShell hop.
The configuration shape and preservation are tested; Windows, Linux and SSH
execution have not been exercised live here.
Resume requests to remote hosts require `agent-session.qoder-resume.v1`, so an
older host is not sent an agent enum it cannot accept. Remote hook installation
and trust preservation have automated coverage.
Successful model output, tool execution and live permission dialogs remain
unverified because the available account has exhausted its credits. Hook event
mapping for these paths follows the official documentation. The China executable
`qoderclicn` is not registered; it was not available for verification.
New Tab follows the existing manual-launch trust behavior: an untrusted folder
can show Qoder's trust dialog. Automated workspace/draft launch paths run the
trust preflight before prompt delivery.
## Sources
- [Official hooks documentation](https://docs.qoder.com/cli/hooks)
- [#15291](https://github.com/stablyai/orca/pull/15291): registration and hook proposal
- [#13311](https://github.com/stablyai/orca/pull/13311): icon asset (commit
`b56197025530adb1b82d97a364a8c3d4a02c0d42`) and agent integration
- [#8611](https://github.com/stablyai/orca/pull/8611) and
[#12910](https://github.com/stablyai/orca/pull/12910): contributor implementations
- [#16540](https://github.com/stablyai/orca/issues/16540): Qoder misidentified as Gemini
Contributor patches informed the integration; they were not applied wholesale.
In particular, the trust marker proposal was replaced using the installed CLI's
observed settings write, and no renderer workaround was added because the live
WebGL terminal rendered correctly.
## Cross-review of open proposals
Reviewed the current patches for all six open Qoder PRs on 2026-09-28:
| PR | Incorporated or confirmed | Deliberate differences |
| --- | --- | --- |
| #7502 (vincent-lxc) | Agent registration, mobile parity, identity before Gemini | Use structural titles and the current public permission flag. |
| #8611 (Eridanus117, building on #7502) | Qoder hook source, notification/permission mapping, session resume | 1.1.64 supports `--prompt-interactive`; preserve startup session identity and ignore compact restarts. |
| #9655 (xingqingzzp-gif) | Cross-checked minimum registration coverage | Placeholder icon and detection-only scope are superseded by the fuller integration; no code copied. |
| #12910 (jyang2004) | Reuse the Claude-compatible installer with an event subset, plus remote installation | Use observed `.qoder` path and `/hook/qoder`; Qwen and the China build remain separate work. |
| #13311 (sorrycc) | Icon, structural title disambiguation, headless detection, display glyph cleanup | No DOM renderer override: 1.1.64 renders correctly in the live WebGL terminal. No unverified executable alias. |
| #15291 (adlternative) | Versioned binary recognition, trust preflight wiring, skills mapping and resume | Trust comes from settings, not `.trusted`; include notifications, permission and failure events with Qoder-specific normalization. |
The rendered left sidebar was checked in the hidden Electron app using a temporary
folder workspace and real Qoder hooks. The workspace fixture needed a parent path
on its project group to appear in the sidebar. No agent status was injected into
the renderer. A real prompt updated the row text and returned a credit-limit error;
the row and workspace card showed failure with the Qoder icon. A DOM observer
recorded the prompt-row transition before failure. Permission/waiting and successful
completion remain automated event-mapping tests, not live model/tool verification.
Commit co-authors credit all six proposal authors for the implementation and
registration groundwork, including xingqingzzp-gif’s minimal registration proposal.
@@ -18,6 +18,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
ante: require('../../../src/shared/agent-icons/ante.png'),
trae: require('../../../src/shared/agent-icons/trae.png'),
'prime-agent': require('../../../src/shared/agent-icons/prime-agent.png'),
qoder: require('../../../src/shared/agent-icons/qoder.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
@@ -26,6 +26,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
zcode: 'zcode.z.ai',
omp: 'omp.sh',
'prime-agent': 'primeintellect.ai',
qoder: 'qoder.com',
gemini: 'gemini.google.com',
antigravity: 'antigravity.google',
goose: 'goose-docs.ai',
@@ -1,3 +1,4 @@
import { qoderHookService } from '../qoder/hook-service'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { ampHookService } from '../amp/hook-service'
@@ -44,6 +45,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['qoder', () => qoderHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
@@ -70,6 +72,7 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
['codex', () => codexHookService.refreshManagedScripts()],
['gemini', () => geminiHookService.refreshManagedScripts()],
['qoder', () => qoderHookService.refreshManagedScripts()],
['antigravity', () => antigravityHookService.refreshManagedScripts()],
['cursor', () => cursorHookService.refreshManagedScripts()],
['droid', () => droidHookService.refreshManagedScripts()],
@@ -88,6 +91,7 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['qoder', () => qoderHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
@@ -112,6 +116,7 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['qoder', () => qoderHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
@@ -82,6 +82,20 @@ const buildersByAgent = new Map<string, CommandBuilders>([
remote: (path) => [getClaudeRemoteCommand(path)]
}
],
[
'qoder',
{
local: (path) => [
getManagedLifecycleHook(path, {
configDirName: '.qoder',
scriptBaseName: 'qoder-hook',
usesWindowsCompatLauncher: true,
windowsHookShell: 'powershell'
}).command
],
remote: (path) => [getClaudeRemoteCommand(path)]
}
],
[
'codex',
{
@@ -206,10 +220,15 @@ describe('managed hook command contract', () => {
expect(commands.length).toBeGreaterThan(0)
for (const command of commands) {
expect(command.length).toBeGreaterThan(0)
// Native Windows Codex evaluates PowerShell variables without Grok's dollar-byte scanner.
// Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner.
const scannedCommand =
agent === 'codex' && platform === 'win32' && command.startsWith('if (Test-Path')
? command.replaceAll('$LASTEXITCODE', '').replaceAll('$env:', '')
platform === 'win32' &&
((agent === 'codex' && command.startsWith('if (Test-Path')) ||
(agent === 'qoder' && command.startsWith('$scriptPath = Join-Path')))
? command
.replaceAll('$LASTEXITCODE', '')
.replaceAll('$env:', '')
.replaceAll('$scriptPath', '')
: command
expect(findBareHookCommandVariables(scannedCommand), command).toEqual([])
}
@@ -1,3 +1,4 @@
import { qoderHookService } from '../qoder/hook-service'
import { describe, expect, it, vi } from 'vitest'
import { parse as parseJsonc } from 'jsonc-parser'
import type { SFTPWrapper } from 'ssh2'
@@ -685,6 +686,18 @@ describe('remote hook service installers', () => {
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
})
it('installs Qoder on the execution host with its own event endpoint', async () => {
const { sftp, fs } = createFakeSftp()
const result = await qoderHookService.installRemote(sftp, '/home/dev/')
expect(result.state).toBe('installed')
expect(result.configPath).toBe('/home/dev/.qoder/settings.json')
const settings = JSON.parse(fs.files.get(result.configPath) ?? '{}')
expect(settings.hooks.SessionEnd).toHaveLength(1)
expect(settings.hooks.Notification).toHaveLength(1)
expect(settings.hooks.TeammateIdle).toBeUndefined()
expect(fs.files.get('/home/dev/.orca/agent-hooks/qoder-hook.sh')).toContain('/hook/qoder')
})
it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => {
const { sftp } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
@@ -22,6 +22,7 @@ import { kimiHookService } from '../kimi/hook-service'
import { dshHookService } from '../dsh/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { qoderHookService } from '../qoder/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers'
@@ -51,6 +52,7 @@ describe('remote hook service registry coverage', () => {
['devin', devinHookService],
['kimi', kimiHookService],
['muse', museHookService],
['qoder', qoderHookService],
['zcode', zcodeHookService],
['dsh', dshHookService]
])
@@ -1,3 +1,4 @@
import { qoderHookService } from '../qoder/hook-service'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
@@ -62,6 +63,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
})
],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
@@ -22,6 +22,7 @@ afterEach(() => vi.restoreAllMocks())
* classifier names no boundary for that source. That is not the same as "can never revive":
* mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */
const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
qoder: 'SessionStart',
claude: 'SessionStart',
kimi: 'UserPromptSubmit',
codex: 'SessionStart',
+1 -1
View File
@@ -6,7 +6,7 @@ import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue'
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity'
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder'
/**
* Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or
+6 -3
View File
@@ -1,6 +1,7 @@
/** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets.
* Split from hook-service.ts so the service owns install/status and this owns script text,
* mirroring the same split under src/main/cursor/. */
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
@@ -18,10 +19,12 @@ import {
export function getManagedScript(
target: 'local' | 'posix' = 'local',
options: {
source?: AgentHookSource
skipWhenDevinImportsClaude?: boolean
skipWhenGrokImportsClaude?: boolean
} = {}
): string {
const source = options.source ?? 'claude'
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
@@ -46,7 +49,7 @@ export function getManagedScript(
]
: []),
// Why: use curl.exe to avoid an extra PowerShell startup per hook.
buildWindowsAgentHookCurlPostCommand('claude'),
buildWindowsAgentHookCurlPostCommand(source),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
@@ -59,7 +62,7 @@ export function getManagedScript(
'printf "{}\\n"',
...buildPosixHookPayloadCapture(),
...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []),
...buildPosixHookSpoolLines('claude'),
...buildPosixHookSpoolLines(source),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
@@ -84,7 +87,7 @@ export function getManagedScript(
' exit 0',
'fi',
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
...buildPosixAgentHookPostCommand(source).map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
+13 -8
View File
@@ -1,4 +1,5 @@
import { existsSync, rmSync, writeFileSync } from 'node:fs'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
@@ -38,6 +39,7 @@ import {
hasSameManagedHookInvocation,
removeManagedHooks,
removeManagedStatusLine,
type ApplyManagedClaudeHooksOptions,
type ClaudeCompatibleHookSettings
} from './hook-settings'
@@ -45,6 +47,8 @@ type ClaudeHookServiceOptions = {
agent: AgentHookInstallStatus['agent']
displayName: string
settings: ClaudeCompatibleHookSettings
source?: AgentHookSource
events?: ApplyManagedClaudeHooksOptions['events']
}
type ClaudeHookInstallOptions = {
@@ -82,7 +86,7 @@ export class ClaudeHookService {
const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings)
const missing: string[] = []
let presentCount = 0
for (const event of CLAUDE_EVENTS) {
for (const event of this.options.events ?? CLAUDE_EVENTS) {
const definitions = Array.isArray(config.hooks?.[event.eventName])
? config.hooks![event.eventName]!
: []
@@ -115,6 +119,7 @@ export class ClaudeHookService {
await refreshManagedScriptIfPresent(
getManagedScriptPath(this.options.settings),
getManagedScript('local', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
@@ -145,11 +150,12 @@ export class ClaudeHookService {
config,
hook,
getManagedScriptFileName(this.options.settings),
this.options.agent === 'claude' ? options : undefined
{ ...(this.options.agent === 'claude' ? options : {}), events: this.options.events }
)
writeManagedScript(
scriptPath,
getManagedScript('local', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
@@ -211,12 +217,10 @@ export class ClaudeHookService {
// Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home.
const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath))
const nextConfig = applyManagedHooks(
config,
hook,
remoteScriptFileName,
this.options.agent === 'claude' ? options : undefined
)
const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName, {
...(this.options.agent === 'claude' ? options : {}),
events: this.options.events
})
// Why: write scripts before settings to avoid settings pointing to missing scripts.
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
@@ -224,6 +228,7 @@ export class ClaudeHookService {
sftp,
remoteScriptPath,
getManagedScript('posix', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
+27 -12
View File
@@ -19,9 +19,10 @@ import { isGitBashAvailable } from '../git-bash'
import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability'
export type ClaudeCompatibleHookSettings = {
configDirName: '.claude' | '.openclaude'
scriptBaseName: 'claude-hook' | 'openclaude-hook'
configDirName: '.claude' | '.openclaude' | '.qoder'
scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook'
usesWindowsCompatLauncher: boolean
windowsHookShell?: 'powershell'
}
export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
@@ -109,6 +110,7 @@ const CLAUDE_SESSION_END_EVENT = {
export type ApplyManagedClaudeHooksOptions = {
claudeVersion?: string
events?: readonly { eventName: string; definition: Omit<HookDefinition, 'hooks'> }[]
}
export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string {
@@ -171,6 +173,14 @@ export function getManagedLifecycleHook(
if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) {
return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }))
}
if (settings.windowsHookShell === 'powershell') {
return {
type: 'command',
command: getWindowsPowerShellLifecycleCommand(scriptPath),
shell: 'powershell',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
}
}
return getWindowsManagedLifecycleHook(scriptPath, options)
}
@@ -190,19 +200,21 @@ export function getWindowsManagedLifecycleHook(
if (directCommand) {
return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
return {
type: 'command',
command: wrapWindowsPowerShellEncodedCommand(getWindowsPowerShellLifecycleCommand(scriptPath)),
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
}
}
function getWindowsPowerShellLifecycleCommand(scriptPath: string): string {
const scriptFileName = win32.basename(scriptPath)
// Why: runtime profile resolution keeps the managed entry portable across users (STA-3348).
const quotedRelativePath = quotePowerShellLiteral(`.orca\\agent-hooks\\${scriptFileName}`)
// Why: compat consumers require neutral JSON even when the managed script is missing (#14818).
const innerCommand =
return (
`$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` +
'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' +
"[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0"
return {
type: 'command',
command: wrapWindowsPowerShellEncodedCommand(innerCommand),
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
}
)
}
export function hasSameManagedHookInvocation(
@@ -211,6 +223,7 @@ export function hasSameManagedHookInvocation(
): boolean {
return (
actual.command === expected.command &&
actual.shell === expected.shell &&
JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? [])
)
}
@@ -228,7 +241,9 @@ export function applyManagedHooks(
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion)
const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS
const events =
options.events ??
(sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS)
for (const event of events) {
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
@@ -240,7 +255,7 @@ export function applyManagedHooks(
nextHooks[event.eventName] = [...cleaned, definition]
}
if (!sessionEndCapable) {
if (!sessionEndCapable && !options.events) {
const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : []
const cleaned = removeManagedCommands(current, isManagedCommand)
if (cleaned.length === 0) {
@@ -35,6 +35,9 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
less: 6,
nano: 2,
opencode: 5,
// Shrink leaves the cursor one column short; also present in the pre-Qoder serializer.
'qoder-no-account': 2,
'qoder-ready': 2,
// Codex 0.157 header border restores with an extra attribute bit (STA-8628 fixtures).
'codex-0157-config-override-embedded-warning': 22,
'codex-0157-effort-override-embedded-warning': 4,
+3
View File
@@ -1,3 +1,4 @@
import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust'
import { ipcMain } from 'electron'
import {
type AgentTrustPreset,
@@ -42,6 +43,8 @@ export function registerAgentTrustHandlers(): void {
}),
{ preset: args.preset, workspacePath: args.workspacePath }
)
} else if (args.preset === 'qoder') {
markQoderWorkspaceTrusted(args.workspacePath)
} else if (args.preset === 'cursor') {
markCursorWorkspaceTrusted(args.workspacePath)
} else if (args.preset === 'copilot') {
+4 -1
View File
@@ -1,3 +1,4 @@
import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust'
/* eslint-disable max-lines */
// Why: worktree create helpers (local + remote) split out of worktrees.ts; the cohesive create flow runs this file just over the per-file line limit.
@@ -439,7 +440,9 @@ async function spawnLocalStartupAndSetupTerminals(args: {
if (isTuiAgent(createdWithAgent)) {
const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust
try {
if (preset === 'cursor') {
if (preset === 'qoder') {
markQoderWorkspaceTrusted(worktree.path)
} else if (preset === 'cursor') {
markCursorWorkspaceTrusted(worktree.path)
} else if (preset === 'copilot') {
markCopilotFolderTrusted(worktree.path)
+92
View File
@@ -0,0 +1,92 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as OsModule from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
const sandbox = vi.hoisted(() => ({ home: '' }))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<typeof OsModule>()),
homedir: () => sandbox.home
}))
vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } }))
import { getManagedLifecycleHook, hasSameManagedHookInvocation } from '../claude/hook-settings'
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
import { qoderHookService, QODER_HOOK_EVENTS } from './hook-service'
import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust'
beforeAll(() => {
sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-'))
mkdirSync(join(sandbox.home, '.qoder'))
})
afterAll(() => {
rmSync(sandbox.home, { recursive: true, force: true })
})
describe('Qoder managed configuration', () => {
it('preserves user hooks and trust, installs idempotently, then removes only Orca hooks', () => {
const path = join(sandbox.home, '.qoder', 'settings.json')
const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] }
writeFileSync(
path,
JSON.stringify({
model: 'custom',
hooks: { Stop: [userHook] },
permissions: { trustDirectories: ['/existing'] }
})
)
expect(qoderHookService.install().state).toBe('installed')
expect(qoderHookService.install().state).toBe('installed')
const installed = JSON.parse(readFileSync(path, 'utf8'))
for (const event of QODER_HOOK_EVENTS) {
expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1)
}
expect(installed.hooks.TeammateIdle).toBeUndefined()
expect(installed.statusLine).toBeUndefined()
markQoderWorkspaceTrusted('/new-workspace')
const trusted = JSON.parse(readFileSync(path, 'utf8'))
expect(trusted.permissions.trustDirectories).toEqual(['/existing', '/new-workspace'])
expect(trusted.hooks).toEqual(installed.hooks)
expect(qoderHookService.remove().state).toBe('not_installed')
expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({
model: 'custom',
hooks: { Stop: [userHook] }
})
})
it('refuses malformed settings instead of overwriting them', () => {
const path = join(sandbox.home, '.qoder', 'settings.json')
writeFileSync(path, '{broken')
expect(qoderHookService.install().state).toBe('error')
markQoderWorkspaceTrusted('/new-workspace')
expect(readFileSync(path, 'utf8')).toBe('{broken')
expect(withQoderTrustedWorkspace({ permissions: 'invalid' }, '/workspace')).toBeNull()
expect(
withQoderTrustedWorkspace({ permissions: { trustDirectories: true } }, '/workspace')
).toBeNull()
})
})
describe('Qoder Windows hook shell', () => {
it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
const hook = getManagedLifecycleHook(
'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd',
{
configDirName: '.qoder',
scriptBaseName: 'qoder-hook',
usesWindowsCompatLauncher: true,
windowsHookShell: 'powershell'
},
{ gitBashAvailable: true }
)
expect(hook.shell).toBe('powershell')
expect(hook.command).toContain('$env:USERPROFILE')
expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/)
expect(createManagedCommandMatcher('qoder-hook.cmd')(hook.command)).toBe(true)
expect(hasSameManagedHookInvocation({ ...hook, shell: undefined }, hook)).toBe(false)
} finally {
vi.restoreAllMocks()
}
})
})
+29
View File
@@ -0,0 +1,29 @@
import { ClaudeHookService } from '../claude/hook-service'
// Qoder documents Claude-shaped hooks at https://docs.qoder.com/cli/hooks.
export const QODER_HOOK_EVENTS = [
'SessionStart',
'SessionEnd',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest',
'Stop',
'StopFailure',
'Notification',
'PostCompact'
] as const
export const qoderHookService = new ClaudeHookService({
agent: 'qoder',
source: 'qoder',
displayName: 'Qoder CLI',
settings: {
configDirName: '.qoder',
scriptBaseName: 'qoder-hook',
usesWindowsCompatLauncher: true,
windowsHookShell: 'powershell'
},
events: QODER_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} }))
})
+78
View File
@@ -0,0 +1,78 @@
import { realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, posix } from 'node:path'
import { isPlainObject, readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils'
import { parseHooksJsonText } from '../agent-hooks/hooks-json-read'
import { isENOENT } from '../ipc/filesystem-path-containment'
import type { IFilesystemProvider } from '../providers/types'
// Qoder 1.1.64 writes this exact shape after accepting its folder-trust prompt.
export function withQoderTrustedWorkspace(
config: Record<string, unknown>,
workspacePath: string
): Record<string, unknown> | null {
if (config.permissions !== undefined && !isPlainObject(config.permissions)) {
return null
}
const permissions = isPlainObject(config.permissions) ? config.permissions : {}
if (permissions.trustDirectories !== undefined && !Array.isArray(permissions.trustDirectories)) {
return null
}
const existing = Array.isArray(permissions.trustDirectories) ? permissions.trustDirectories : []
if (existing.includes(workspacePath)) {
return config
}
return {
...config,
permissions: { ...permissions, trustDirectories: [...existing, workspacePath] }
}
}
export function markQoderWorkspaceTrusted(workspacePath: string): void {
let canonicalPath = workspacePath
try {
canonicalPath = realpathSync.native(workspacePath)
} catch {
/* Keep the supplied path when absent. */
}
const configPath = join(homedir(), '.qoder', 'settings.json')
const config = readHooksJson(configPath)
if (!config) {
return
}
const updated = withQoderTrustedWorkspace(config, canonicalPath)
if (updated && updated !== config) {
writeHooksJson(configPath, updated)
}
}
export async function markRemoteQoderWorkspaceTrusted(
fsProvider: IFilesystemProvider,
remoteHome: string,
workspacePath: string
): Promise<void> {
const configDir = posix.join(remoteHome, '.qoder')
const configPath = posix.join(configDir, 'settings.json')
let config: Record<string, unknown> | null
try {
const result = await fsProvider.readFile(configPath)
if (result.isBinary) {
return
}
config = parseHooksJsonText(result.content)
} catch (error) {
if (!isENOENT(error)) {
return
}
config = {}
}
if (!config) {
return
}
const updated = withQoderTrustedWorkspace(config, workspacePath)
if (!updated || updated === config) {
return
}
await fsProvider.createDir(configDir)
await fsProvider.writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`)
}
@@ -160,3 +160,66 @@ describe('markRemoteAgentWorkspaceTrusted', () => {
expect(fsProvider.writeFile).not.toHaveBeenCalled()
})
})
describe('remote Qoder trust', () => {
it('preserves settings and hooks while trusting the execution host canonical folder', async () => {
const original = {
model: 'custom',
hooks: { Stop: [] },
permissions: { trustDirectories: ['/old'], other: true }
}
const fsProvider = makeFsProvider({
readFile: vi.fn(async () => ({ content: JSON.stringify(original), isBinary: false }))
})
mocks.getActiveMultiplexer.mockReturnValue({
request: vi.fn(async () => ({ resolvedPath: '/home/remote' }))
})
mocks.getSshFilesystemProvider.mockReturnValue(fsProvider)
await markRemoteAgentWorkspaceTrusted({
preset: 'qoder',
connectionId: 'ssh-qoder',
workspacePath: '/folder'
})
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'/home/remote/.qoder/settings.json',
`${JSON.stringify(
{
...original,
permissions: { ...original.permissions, trustDirectories: ['/old', '/real/folder'] }
},
null,
2
)}\n`
)
})
it.each(['malformed', 'denied', 'binary', 'trusted'])(
'does not overwrite %s remote settings',
async (kind) => {
const fsProvider = makeFsProvider({
readFile: vi.fn(async () => {
if (kind === 'denied') {
throw Object.assign(new Error('denied'), { code: 'EACCES' })
}
return {
content:
kind === 'trusted'
? JSON.stringify({ permissions: { trustDirectories: ['/real/folder'] } })
: '{broken',
isBinary: kind === 'binary'
}
})
})
mocks.getActiveMultiplexer.mockReturnValue({
request: vi.fn(async () => ({ resolvedPath: '/home/remote' }))
})
mocks.getSshFilesystemProvider.mockReturnValue(fsProvider)
await markRemoteAgentWorkspaceTrusted({
preset: 'qoder',
connectionId: 'ssh-qoder',
workspacePath: '/folder'
})
expect(fsProvider.writeFile).not.toHaveBeenCalled()
}
)
})
+4 -1
View File
@@ -1,3 +1,4 @@
import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust'
import type { AgentTrustPreset } from './agent-trust-presets'
import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust'
import { getActiveMultiplexer } from './ssh/ssh-target-registry'
@@ -20,7 +21,9 @@ export async function markRemoteAgentWorkspaceTrusted(args: {
}
const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath)
if (args.preset === 'codex') {
if (args.preset === 'qoder') {
await markRemoteQoderWorkspaceTrusted(fsProvider, home, workspacePath)
} else if (args.preset === 'codex') {
await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath)
} else if (args.preset === 'cursor') {
await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath)
@@ -0,0 +1,17 @@
{
"capturedAt": "2026-09-28T08:03:27.131Z",
"platform": "darwin",
"command": [
"qodercli",
"--config-dir",
"/tmp/orca-qoder-probe/config",
"--cwd",
"/tmp/orca-qoder-probe/workspace",
"--prompt-interactive",
"Reply with hello."
],
"cols": 100,
"rows": 32,
"note": "Qoder CLI 1.1.64; isolated trusted folder; no account; lifecycle hook capture",
"exitCode": 129
}
@@ -0,0 +1,66 @@
[?u]11;?\[>q[>4;?m␍]0;◇ Qoder CLI | Ready ]8;;78␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h␍
 ██████╭─Tipsforgettingstarted────────────────────────────────╮␍
████│1.UseAGENTS.mdfilestocustomizeinteractions│␍
██████QoderCLIv1.1.64│2./helpformoreinformation│␍
████│3.Askcodingquestions,editcodeorruncommands│␍
 ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│␍
╰───────────────────────────────────────────────────────────╯␍
␍
␍
 Authenticating␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
␍
⠋Waitingforauthentication...␍
␍
Escback␍
[>0q␍Welcome to Qoder CLI␍Signintogetstarted,orexittheapplication.␍␍❯1.Signintocontinue␍ 2. Exittheapplication␍
␍
↑/↓navigate·Enterselect·Escback␍
]0;✦ Qoder CLI | Working ␍ > Reply with hello.␍␍ Welcome to Qoder CLI␍────────────────────────────────────────────────────────────────────────────────────────────────────␍Sign in toget started, orexittheapplication.␍␍❯1.Signintocontinue␍ 2. Exit the application␍
␍
↑/↓navigate·Enterselect·Escback␍
]0;✦ Reply with hello. | Working ]0;◇ Reply with hello. | Ready ␍ x Qoder authenticationisnotready.Pleasesigninagain(e.g.via/login)beforesendinga␍ message.␍ Welcome to Qoder CLI␍────────────────────────────────────────────────────────────────────────────────────────────────────␍Sign in toget started, orexittheapplication.␍␍❯1.Signintocontinue␍ 2. Exit the application␍
␍
↑/↓navigate·Enterselect·Escback␍
␍␍

@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-28T08:15:00.339Z",
"platform": "darwin",
"command": ["qodercli", "--cwd", "/tmp/orca-qoder-probe/workspace"],
"cols": 100,
"rows": 32,
"note": "Qoder CLI 1.1.64; existing browser-login config; credit limit reached; no prompt submitted",
"exitCode": 129
}
@@ -0,0 +1,66 @@
[?u]11;?\[>q[>4;?m␍]0;◇ Qoder CLI | Ready ]8;;78␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h␍
 ██████╭─Tipsforgettingstarted──────────────────────────────╮␍
████│1.UseAGENTS.mdfilestocustomizeinteractions│␍
██████QoderCLIv1.1.64│2./helpformoreinformation│␍
████│3.Askcodingquestions,editcodeorruncommands│␍
 ██████SignedinBrowserLogin│4.Bespecificforthebestresults│␍
╰─────────────────────────────────────────────────────────╯␍
␍
␍
?forshortcuts␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
Shift+TabtoAcceptEdits␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
> Typeyourmessageor@path/to/file␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
Model·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace␍
[>0q␍Qwen3.8-Max Model · ctx░░░░░░░░░░ 0% · /privte/tmp/oca-qoder-robe/workspace␍
␍●Warning:Truecolor(24-bit)supportnotdetected.Usingaterminalwithtruecolor enabled will␍ result in a better visual experience.␍␍ ? for shortcuts␍────────────────────────────────────────────────────────────────────────────────────────────────────␍ Shift+Tab to Accept Edits 1 MCP server␍────────────────────────────────────────────────────────────────────────────────────────────────────␍
> Typeyourmessageor@path/to/file␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
Qwen3.8-MaxModel·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace␍
␍1MCPserver · 34kills␍
␍Creditsexhausted.Use/usagefordetailsor/upgradefor more.␍

@@ -0,0 +1,15 @@
{
"capturedAt": "2026-09-28T07:56:42.619Z",
"platform": "darwin",
"command": [
"qodercli",
"--config-dir",
"/tmp/orca-qoder-probe/config",
"--cwd",
"/tmp/orca-qoder-probe/workspace"
],
"cols": 100,
"rows": 32,
"note": "Qoder CLI 1.1.64; isolated configuration; no account",
"exitCode": 129
}
@@ -0,0 +1,58 @@
[?u]11;?\[>q[>4;?m␍]0;◇ Qoder CLI | Ready ]8;;78␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
␍
[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h␍
 ██████╭─Tipsforgettingstarted────────────────────────────────╮␍
████│1.UseAGENTS.mdfilestocustomizeinteractions│␍
██████QoderCLIv1.1.64│2./helpformoreinformation│␍
████│3.Askcodingquestions,editcodeorruncommands│␍
 ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│␍
╰───────────────────────────────────────────────────────────╯␍
␍
␍
 Doyoutrustthefilesinthisfolder?␍
────────────────────────────────────────────────────────────────────────────────────────────────────␍
Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan␍
read,modify,andexecutefileshere,andwillloadlocalconfigurations(commands,hooks,MCP␍
servers,agents,skills,settings).␍
␍
/private/tmp/orca-qoder-probe/workspace␍
␍
❯1.Trustfolder␍
2.Don'ttrustandexit␍
␍
↑/↓navigate·Enterselect·Escback␍
[>0q␍ ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwill␍ result in a better visual experience.␍␍ Do you trust the files in this folder?␍────────────────────────────────────────────────────────────────────────────────────────────────────␍Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan␍read, modify, and execute files here, andwillloadlocalconfigurations(commands,hooks,MCP␍servers,agents,skills,settings).␍␍/private/tmp/orca-qoder-probe/workspace␍ ❯ 1. Trust folder␍
2.Don'ttrustandexit␍
␍
↑/↓navigate·Enterselect·Escback␍

@@ -0,0 +1,11 @@
// Captured Qoder 1.1.64 paints its idle OSC title even while the trust menu owns input.
export function isQoderComposerReady(screenLines: readonly string[] | null): boolean {
if (!screenLines) {
return false
}
const screen = screenLines.join('\n').toLowerCase()
return (
screen.includes('type your message or @path/to/file') &&
!screen.includes("don't trust and exit")
)
}
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import { extractLastOscTitle } from '../../shared/osc-title-extraction'
import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
describe('captured Qoder 1.1.64 startup', () => {
it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])(
'preserves Qoder identity in %s',
async (fixture) => {
const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8')
// The recorder's shutdown clears the OSC title; inspect the live capture before that reset.
const title = extractLastOscTitle(
data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '')
)
expect(title).toContain(' | Ready')
expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI')
const { runtime, handle } = await createTranscriptPane({
paneTitle: title ?? '',
foregroundProcess: 'qodercli-1.1.64',
launchAgent: 'qoder',
data,
size: { cols: 100, rows: 32 }
})
const shown = await runtime.showTerminal(handle)
expect(shown.agentIdentity).toBe('qoder')
if (fixture === 'qoder-trust-dialog') {
const readiness = await runtime
.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 600 })
.catch(() => null)
expect(readiness?.satisfied ?? false).toBe(false)
}
if (fixture === 'qoder-ready') {
const readiness = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs: 1500
})
expect(readiness.satisfied).toBe(true)
}
}
)
})
@@ -1,3 +1,4 @@
import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust'
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
import type { Repo } from '../../shared/repo-types'
@@ -195,7 +196,9 @@ export async function markLocalWorktreeTrusted(
return
}
try {
if (preset === 'cursor') {
if (preset === 'qoder') {
markQoderWorkspaceTrusted(workspacePath)
} else if (preset === 'cursor') {
markCursorWorkspaceTrusted(workspacePath)
} else if (preset === 'copilot') {
markCopilotFolderTrusted(workspacePath)
@@ -1,3 +1,4 @@
import { isQoderComposerReady } from './qoder-terminal-readiness'
import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo'
import {
detectAgentStatusFromTitle,
@@ -61,6 +62,9 @@ export function isKnownReadyPromptBody(
agent: TuiAgent | null,
readScreenLines: () => readonly string[] | null
): boolean {
if (agent === 'qoder') {
return isQoderComposerReady(readScreenLines())
}
if (isKnownReadyPromptPreview(waitText)) {
return true
}
+13 -1
View File
@@ -234,6 +234,18 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
if (blockedReason) {
return { kind: 'blocked', reason: blockedReason }
}
// Qoder publishes "Ready" before its trust dialog is dismissed; only its composer proves input is live.
if (input.agent === 'qoder') {
if (
hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) ||
input.record.lastAgentStatus === 'working'
) {
return WORKING
}
return input.readPositiveBodyEvidence()
? READY_STRONG
: { kind: 'pending', quietForeground: false }
}
// Why the title before the body: both are tier 1, so either settles, but the title is a
// memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order.
if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) {
@@ -325,7 +337,7 @@ export function ptyTuiIdleEvidence(
record: pty,
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()),
readPositiveBodyEvidence: () =>
source.getAdoptedPtyIdleStatus(pty) === 'idle' ||
(agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)),
readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()),
agent,
+1 -1
View File
@@ -50,7 +50,7 @@ export type AgentStatusApi = {
export type AgentTrustApi = {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity'
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder'
workspacePath: string
connectionId?: string
}) => Promise<void>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PreloadApi } from '../api-types'
export const agentTrustApi = {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity'
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder'
workspacePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
@@ -1,3 +1,4 @@
import { isQoderTerminalTitle } from '../../../../shared/qoder-terminal-title'
import {
detectAgentStatusFromTitle,
isGeminiTerminalTitle,
@@ -21,6 +22,7 @@ export function titleHasExplicitAgentIdentity(title: string): boolean {
title.startsWith('. ') ||
title.startsWith('* ') ||
title.startsWith('\u2733') ||
isQoderTerminalTitle(title) ||
isGeminiTerminalTitle(title) ||
isPiTerminalTitle(title)
) {
+1
View File
@@ -612,6 +612,7 @@
"060d152fb5": "Trae",
"muse_label": "Muse",
"dsh_label": "DeepSeek Harness",
"qoder_label": "Qoder CLI",
"zcode_label": "ZCode",
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
+7
View File
@@ -134,6 +134,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] =>
searchAliases: ['deepseek', 'dsh', 'dst', 'deepseek harness'],
homepageUrl: 'https://deepseek-harness.github.io/deepseek-harness/'
},
{
id: 'qoder',
label: translate('auto.lib.agent.catalog.qoder_label', 'Qoder CLI'),
cmd: 'qodercli',
faviconDomain: 'qoder.com',
homepageUrl: 'https://docs.qoder.com/cli/overview'
},
{
id: 'zcode',
label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'),
@@ -1,3 +1,4 @@
import qoderUrl from '../../../shared/agent-icons/qoder.png?url'
import type { TuiAgent } from '../../../shared/tui-agent'
import grokUrl from '../../../shared/agent-icons/grok.png?url'
import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url'
@@ -42,6 +43,7 @@ export const AGENT_FAVICON_ASSETS: Partial<Record<TuiAgent, string>> = {
ante: anteUrl,
trae: traeUrl,
'prime-agent': primeAgentUrl,
qoder: qoderUrl,
gemini: geminiUrl,
antigravity: antigravityUrl,
goose: gooseUrl,
+1
View File
@@ -109,6 +109,7 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
pi: true,
omp: true,
'prime-agent': true,
qoder: true,
gemini: true,
antigravity: true,
aider: true,
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume'
import {
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
@@ -56,6 +57,7 @@ describe('agentResumeHostAuthorityCapability', () => {
).toEqual({
claude: undefined,
codex: undefined,
qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
@@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
@@ -20,6 +21,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = {
// These shipped inside agent-session.host-authority.v1's enum, so the generic probe covers them.
claude: undefined,
codex: undefined,
qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
@@ -0,0 +1,6 @@
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"startup"}
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"UserPromptSubmit","permission_mode":"default","prompt":"Reply with hello.","request_set_id":"00000000-0000-4000-8000-000000000000"}
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"Notification","permission_mode":"default","notification_type":"idle_prompt","message":"Qoder CLI finished responding and is awaiting input.","details":{"streamingState":"idle"}}
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"}
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"resume"}
{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"}
+2
View File
@@ -1,3 +1,4 @@
import { isQoderHeadlessCommand } from './qoder-headless-command'
import { isAnteHeadlessOneShotCommand } from './ante-headless-command'
import { isDshNonInteractiveCommand } from './dsh-launch-command'
import { isMuseHeadlessOneShotCommand } from './muse-headless-command'
@@ -15,6 +16,7 @@ import type { TuiAgent } from './tui-agent'
const HEADLESS_ONE_SHOT_MATCHERS: Partial<
Record<TuiAgent, (tokens: readonly string[]) => boolean>
> = {
qoder: isQoderHeadlessCommand,
claude: isPrintModeHeadlessOneShotCommand,
trae: isPrintModeHeadlessOneShotCommand,
'prime-agent': isPrimeAgentHeadlessOneShotCommand,
@@ -1,3 +1,4 @@
import { normalizeQoderEvent } from './providers/qoder-events'
import type { ParsedAgentStatusPayload } from '../agent-status-types'
import type { AgentHookSource } from '../agent-hook-relay'
import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript'
@@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: {
case 'devin':
payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload)
break
case 'qoder':
payload = normalizeQoderEvent(state, eventName, promptText, paneKey, hookPayload)
break
case 'kimi':
payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload)
break
@@ -25,6 +25,7 @@ import { extractHermesToolFields } from './providers/hermes-tool-fields'
export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
switch (source) {
case 'qoder':
case 'claude':
// Why: SessionStart lands an idle row (STA-3386) and must also drop stale
// tool/prompt caches left by the pane's previous session.
@@ -138,6 +139,7 @@ export function extractToolFields(
): ToolSnapshot {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor.
switch (source) {
case 'qoder':
case 'claude':
// Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim.
// falls through
@@ -0,0 +1,87 @@
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
import {
normalizeAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../agent-status-types'
import type { HookListenerState } from '../listener-state'
import {
resolvePrompt,
resolveToolState,
shouldIgnoreCompactContinuationUserPromptSubmit
} from '../prompt-fields'
import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
import { readString } from '../tool-input-preview'
function readQoderState(
eventName: unknown,
payload: Record<string, unknown>
): 'working' | 'waiting' | 'done' | null {
switch (eventName) {
case 'SessionStart':
// Compaction restarts the session during an active turn.
return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null
case 'UserPromptSubmit':
case 'PostToolUse':
case 'PostToolUseFailure':
return 'working'
case 'PreToolUse':
return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working'
case 'PermissionRequest':
return 'waiting'
case 'Stop':
case 'StopFailure':
case 'SessionEnd':
return 'done'
case 'PostCompact':
return payload.trigger === 'manual' ? 'done' : null
case 'Notification':
if (
payload.notification_type === 'permission_prompt' ||
payload.notification_type === 'elicitation_dialog'
) {
return 'waiting'
}
return payload.notification_type === 'idle_prompt' ? 'done' : null
default:
return null
}
}
export function normalizeQoderEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
return null
}
const stateName = readQoderState(eventName, hookPayload)
if (!stateName) {
return null
}
const resetOnNewTurn = isNewTurnEvent('qoder', eventName)
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('qoder', eventName, hookPayload),
{ resetOnNewTurn }
)
return normalizeAgentStatusPayload({
state: stateName,
agentType: 'qoder',
prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }),
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined,
interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined,
mainAgent:
eventName === 'StopFailure'
? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() }
: undefined
})
}
@@ -5,6 +5,7 @@ import type { AgentHookSource } from '../agent-hook-relay'
export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>> = Object.freeze({
'/hook/claude': 'claude',
'/hook/codex': 'codex',
'/hook/qoder': 'qoder',
'/hook/gemini': 'gemini',
'/hook/antigravity': 'antigravity',
'/hook/amp': 'amp',
+1
View File
@@ -37,6 +37,7 @@ import type { AgentHookTarget } from './agent-hook-types'
const AGENT_HOOK_SOURCES = [
'claude',
'codex',
'qoder',
'gemini',
'antigravity',
'amp',
+1
View File
@@ -7,6 +7,7 @@ export const AGENT_HOOK_TARGETS = [
'claude',
'openclaude',
'codex',
'qoder',
'gemini',
'antigravity',
'amp',
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -25,6 +25,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
pi: 'pi',
omp: 'omp',
'prime-agent': 'prime-agent',
qoder: 'qoder',
gemini: 'gemini',
antigravity: 'antigravity',
aider: 'aider',
+5
View File
@@ -73,6 +73,10 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
if (normalized.startsWith('codex-')) {
return PROCESS_TO_AGENT.get('codex')
}
// Qoder's launcher resolves to a versioned native binary.
if (/^qodercli-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qodercli')
}
if (normalized.startsWith('grok-')) {
return PROCESS_TO_AGENT.get('grok')
}
@@ -158,6 +162,7 @@ export function isExpectedAgentProcess(
return (
normalizedProcess === normalizedExpected ||
normalizedProcess.startsWith(`${normalizedExpected}.`) ||
(normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) ||
(normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-'))
)
}
+4
View File
@@ -6,6 +6,7 @@ import type { TuiAgent } from './tui-agent'
export const RESUMABLE_TUI_AGENTS = [
'claude',
'codex',
'qoder',
'gemini',
'antigravity',
'opencode',
@@ -194,6 +195,7 @@ export function extractAgentProviderSession(
// Native-chat agents: also capture the hook's authoritative transcript_path,
// since recent Claude Code names the transcript file with a UUID that differs
// from the hook session_id (so the id-based glob no longer finds it).
case 'qoder':
case 'claude':
case 'codex': {
const id = readSessionId(payload, ['session_id'])
@@ -279,6 +281,8 @@ export function getAgentResumeArgv(
return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null
case 'codex':
return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null
case 'qoder':
return providerSession.key === 'session_id' ? ['qodercli', '--resume', id] : null
case 'gemini':
return providerSession.key === 'session_id' ? ['gemini', '--resume', id] : null
case 'antigravity':
+6 -11
View File
@@ -1,3 +1,6 @@
import { isDshTerminalTitle } from './dsh-terminal-title'
export { DSH_WHALE, isDshTerminalTitle } from './dsh-terminal-title'
import { isQoderTerminalTitle } from './qoder-terminal-title'
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
@@ -21,17 +24,6 @@ export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp(
'i'
)
/** DSH-TUI titles are always `<prefix> 🐋 <session title>` (its Chat screen's
* `useTerminalTitle`), and the whale is the only part no other agent emits. It has to
* outrank the Gemini glyphs below: DSH's IDLE prefix is `✦`, which is Gemini's WORKING
* glyph, so without this a resting DSH pane reads as a working Gemini — wrong agent,
* wrong state. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. */
export const DSH_WHALE = '\u{1F40B}' // 🐋
export function isDshTerminalTitle(title: string): boolean {
return title.includes(DSH_WHALE)
}
export const GEMINI_WORKING = '\u2726' // ✦
export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲
export const GEMINI_IDLE = '\u25c7' // ◇
@@ -68,6 +60,9 @@ export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g
function computeIsGeminiTerminalTitle(title: string): boolean {
// Why first: see isDshTerminalTitle — the two agents share the `✦` glyph.
if (isQoderTerminalTitle(title)) {
return false
}
if (isDshTerminalTitle(title)) {
return false
}
@@ -5,6 +5,11 @@ import {
} from './agent-title-decoration'
describe('stripLeadingAgentTitleDecoration', () => {
it('strips Qoder permission decoration without stripping triangles inside a title', () => {
expect(stripLeadingAgentTitleDecoration('▲ Qoder CLI')).toBe('Qoder CLI')
expect(stripLeadingAgentTitleDecoration('deploy ▲ complete')).toBe('deploy ▲ complete')
})
it("strips Claude's ✳ idle glyph", () => {
expect(stripLeadingAgentTitleDecoration('✳ Claude Code')).toBe('Claude Code')
})
+2 -2
View File
@@ -1,11 +1,11 @@
// Leading status decorations that coding agents prepend to their OSC title —
// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), braille and quarter-circle spinners,
// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), Qoder’s ▲, braille and quarter-circle spinners,
// and Claude's '. '/'* ' working/idle prefixes. Once the tab bar shows the
// provider icon, this leading glyph reads as a redundant second icon, so strip
// it from the displayed title. Scoped to titles we already know belong to an agent.
const LEADING_AGENT_TITLE_DECORATION_RE =
// eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges
/^(?:[✳✦⏲◇✋⠀-⣿◐-◓]+|[.*]\s)\s*/
/^(?:[✳✦⏲◇✋▲⠀-⣿◐-◓]+|[.*]\s)\s*/
export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string {
return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart()
+4
View File
@@ -1,3 +1,4 @@
import { isQoderTerminalTitle } from './qoder-terminal-title'
import { getPiStateTitleBrand } from './pi-state-title-marker'
import {
AGY_AGENT_NAME_RE,
@@ -72,6 +73,9 @@ function computeAgentLabel(title: string): string | null {
if (piStateBrand) {
return piStateBrand
}
if (isQoderTerminalTitle(title)) {
return 'Qoder CLI'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
+9
View File
@@ -1,3 +1,4 @@
import { qoderTitleStatus } from './qoder-terminal-title'
import {
AGY_AGENT_NAME_RE,
BRAILLE_SPINNER_RE,
@@ -129,6 +130,10 @@ export function createAgentStatusTracker(
* Normalize high-churn agent titles into stable display labels before storage.
*/
export function normalizeTerminalTitle(title: string): string {
const qoderStatus = qoderTitleStatus(title)
if (qoderStatus) {
return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} Qoder CLI`
}
if (!title) {
return title
}
@@ -181,6 +186,10 @@ function canonicalizeBrailleSpinnerFrame(title: string): string {
}
function computeAgentStatusFromTitle(title: string): AgentStatus | null {
const qoderStatus = qoderTitleStatus(title)
if (qoderStatus) {
return qoderStatus
}
if (!title || isClaudeManagementTitle(title)) {
return null
}
+2
View File
@@ -6,6 +6,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
claude: 'Claude',
openclaude: 'OpenClaude',
codex: 'Codex',
qoder: 'Qoder CLI',
qodercli: 'Qoder CLI',
gemini: 'Gemini',
antigravity: 'Antigravity',
amp: 'Amp',
+10
View File
@@ -0,0 +1,10 @@
/** DSH-TUI titles are always `<prefix> 🐋 <session title>` (its Chat screen's
* `useTerminalTitle`), and the whale is the only part no other agent emits. It has to
* outrank the Gemini glyphs below: DSH's IDLE prefix is `✦`, which is Gemini's WORKING
* glyph, so without this a resting DSH pane reads as a working Gemini — wrong agent,
* wrong state. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. */
export const DSH_WHALE = '\u{1F40B}' // 🐋
export function isDshTerminalTitle(title: string): boolean {
return title.includes(DSH_WHALE)
}
+3
View File
@@ -235,6 +235,8 @@ export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
'agent-session.opencode2-resume.v1' as const
export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY =
'agent-session.qoder-resume.v1' as const
export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY =
'agent-session.zcode-resume.v1' as const
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
@@ -398,6 +400,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
+160
View File
@@ -0,0 +1,160 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
recognizeAgentProcessFromCommandLine,
isExpectedAgentProcess
} from './agent-process-recognition'
import {
getAgentLabel,
isGeminiTerminalTitle,
normalizeTerminalTitle,
detectAgentStatusFromTitle
} from './agent-detection'
import { buildAgentStartupPlan } from './tui-agent-startup'
import { getAgentResumeArgv } from './agent-session-resume'
import { createHookListenerState } from './agent-hook-listener/listener-state'
import { normalizeAndAccept } from './agent-hook-listener-test-harness'
describe('Qoder agent identity and lifecycle', () => {
it('recognizes the installed versioned binary on native platforms and excludes headless runs', () => {
for (const command of [
'qodercli',
'/home/dev/.qoder/bin/qodercli/qodercli-1.1.64',
'C:\\Qoder\\qodercli-1.1.64.exe'
]) {
expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder')
expect(isExpectedAgentProcess(command, 'qodercli')).toBe(true)
}
expect(recognizeAgentProcessFromCommandLine('qodercli --print hello')).toBeNull()
for (const args of [
'-o json',
'--input-format=stream-json',
'--remote-control session',
'--acp',
'--delete-session 1'
]) {
expect(recognizeAgentProcessFromCommandLine(`qodercli ${args}`)).toBeNull()
}
expect(recognizeAgentProcessFromCommandLine('qodercli --prompt-interactive hello')?.agent).toBe(
'qoder'
)
expect(recognizeAgentProcessFromCommandLine('qodercli-unrelated')).toBeNull()
})
it.each([
['◇ Qoder CLI | Ready', 'idle'],
['✦ Implement feature | Thinking', 'working'],
['▲ Implement feature | Confirm', 'permission']
] as const)('keeps %s attributed through title storage', (title, status) => {
const stored = normalizeTerminalTitle(title.padEnd(80))
expect(getAgentLabel(stored)).toBe('Qoder CLI')
expect(isGeminiTerminalTitle(title)).toBe(false)
expect(detectAgentStatusFromTitle(stored)).toBe(status)
expect(normalizeTerminalTitle(stored)).toBe(stored)
})
it('keeps upstream Gemini titles and arbitrary triangle text unchanged', () => {
expect(isGeminiTerminalTitle('✦ Working (project)')).toBe(true)
expect(getAgentLabel('deploy ▲ complete')).not.toBe('Qoder CLI')
})
it('tracks start, work, permission, completion and resume without becoming Claude', () => {
const state = createHookListenerState()
const session = { session_id: 'qoder-session', transcript_path: '/tmp/qoder-session.jsonl' }
const send = (hook_event_name: string, extra = {}) =>
normalizeAndAccept(state, 'qoder', { ...session, hook_event_name, ...extra })
const started = send('SessionStart', { source: 'startup' })
expect(started?.payload).toMatchObject({
agentType: 'qoder',
state: 'done',
sessionBoundary: true
})
expect(started?.providerSession).toMatchObject({ id: 'qoder-session' })
expect(send('UserPromptSubmit', { prompt: 'Fix the bug' })?.payload).toMatchObject({
state: 'working',
agentType: 'qoder',
prompt: 'Fix the bug'
})
expect(
send('PermissionRequest', { tool_name: 'Bash', tool_input: { command: 'pwd' } })?.payload
).toMatchObject({ state: 'waiting', toolName: 'Bash' })
expect(send('PostToolUse', { tool_name: 'Bash' })?.payload.state).toBe('working')
expect(send('SessionStart', { source: 'compact' })).toBeNull()
expect(send('Stop', { last_assistant_message: 'Fixed.' })?.payload).toMatchObject({
state: 'done',
lastAssistantMessage: 'Fixed.'
})
expect(send('StopFailure', { error: 'Unavailable' })?.payload.mainAgent).toMatchObject({
outcome: 'failure'
})
expect(getAgentResumeArgv('qoder', { key: 'session_id', id: 'qoder-session' })).toEqual([
'qodercli',
'--resume',
'qoder-session'
])
})
})
it('replays captured unauthenticated startup, prompt, idle and resume hooks', () => {
const state = createHookListenerState()
const lines = readFileSync(
join(__dirname, '__fixtures__', 'qoder-no-account-hooks.jsonl'),
'utf8'
)
.trim()
.split('\n')
const events = lines.map((line) => normalizeAndAccept(state, 'qoder', JSON.parse(line)))
expect(events.map((event) => event?.payload.state)).toEqual([
'done',
'working',
'done',
'done',
'done',
'done'
])
expect(events.every((event) => event?.payload.agentType === 'qoder')).toBe(true)
expect(events[4]?.payload.sessionBoundary).toBe(true)
expect(events[4]?.providerSession?.id).toBe(events[0]?.providerSession?.id)
})
it.each(['darwin', 'linux', 'win32'] as const)(
'launches a Qoder prompt safely on %s',
(platform) => {
const plan = buildAgentStartupPlan({
agent: 'qoder',
prompt: "fix Bob's branch; echo $HOME",
cmdOverrides: {},
platform
})
expect(plan?.expectedProcess).toBe('qodercli')
expect(plan?.followupPrompt).toBeNull()
expect(plan?.launchCommand).toBe(
platform === 'win32'
? "qodercli --prompt-interactive 'fix Bob''s branch; echo $HOME'"
: `qodercli --prompt-interactive 'fix Bob'"'"'s branch; echo $HOME'`
)
}
)
it('does not rewrite DSH titles containing a pipe as Qoder', () => {
const title = '✦ 🐋 refactor parser | run tests'
expect(normalizeTerminalTitle(title)).toBe(title)
expect(getAgentLabel(title)).not.toBe('Qoder CLI')
expect(detectAgentStatusFromTitle(title)).not.toBe('working')
})
it('settles manual compaction without interrupting automatic compaction', () => {
const state = createHookListenerState()
const send = (hook_event_name: string, extra = {}) =>
normalizeAndAccept(state, 'qoder', { hook_event_name, ...extra })
expect(send('UserPromptSubmit', { prompt: 'Refactor' })?.payload.state).toBe('working')
expect(send('PreCompact', { trigger: 'manual' })).toBeNull()
expect(send('PostCompact', { trigger: 'auto' })).toBeNull()
expect(send('SessionStart', { source: 'compact' })).toBeNull()
expect(send('PostCompact', { trigger: 'manual' })?.payload).toMatchObject({
state: 'done',
sessionBoundary: true,
agentType: 'qoder'
})
})
+31
View File
@@ -0,0 +1,31 @@
import { isPrintModeHeadlessOneShotCommand, optionName } from './print-mode-headless-command'
export function isQoderHeadlessCommand(tokens: readonly string[]): boolean {
if (isPrintModeHeadlessOneShotCommand(tokens)) {
return true
}
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index]
if (token === '--') {
return false
}
const name = optionName(token)
const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[index + 1]
if (name === '-o' && (value === 'json' || value === 'stream-json')) {
return true
}
if (name === '--input-format' && value === 'stream-json') {
return true
}
if (
name === '--remote-control' ||
name === '--remote' ||
name === '--list-sessions' ||
name === '--delete-session' ||
name === '--acp'
) {
return true
}
}
return false
}
+21
View File
@@ -0,0 +1,21 @@
import { isDshTerminalTitle } from './dsh-terminal-title'
// Qoder 1.1.64: one space after the glyph, then a session title and a pipe-delimited status.
const QODER_DYNAMIC_TITLE_RE = /^[✦◇▲] (?! ).*\s\|\s[^|]+$/
const QODER_LABEL_RE = /^(?:[✦◇▲] )?Qoder CLI(?: CN)?(?: \(.*\))?$/
export function isQoderTerminalTitle(title: string): boolean {
if (isDshTerminalTitle(title)) {
return false
}
const trimmed = title.trim()
return QODER_DYNAMIC_TITLE_RE.test(trimmed) || QODER_LABEL_RE.test(trimmed)
}
export function qoderTitleStatus(title: string): 'working' | 'permission' | 'idle' | null {
if (!isQoderTerminalTitle(title)) {
return null
}
const glyph = title.trimStart()[0]
return glyph === '✦' ? 'working' : glyph === '▲' ? 'permission' : 'idle'
}
+1
View File
@@ -24,6 +24,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = {
pi: 'pi',
omp: null,
'prime-agent': null,
qoder: 'qoder',
gemini: 'gemini-cli',
antigravity: 'antigravity',
aider: null,
+1
View File
@@ -19,6 +19,7 @@ export const AGENT_KIND_VALUES = [
'pi',
'omp',
'prime-agent',
'qoder',
'gemini',
'antigravity',
'aider',
+8
View File
@@ -1,3 +1,4 @@
import { isQoderTerminalTitle } from './qoder-terminal-title'
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
@@ -42,6 +43,9 @@ export function containsBrailleSpinner(title: string): boolean {
export function isGeminiTerminalTitle(title: string): boolean {
// Why: DSH-TUI's idle prefix is `✦`, Gemini's working glyph. The whale is decisive
// and is checked first so a resting DSH pane never reads as a working Gemini.
if (isQoderTerminalTitle(title)) {
return false
}
if (isDshTerminalTitle(title)) {
return false
}
@@ -170,6 +174,9 @@ function computeAgentLabel(title: string): string | null {
if (isDshTerminalTitle(title)) {
return 'DeepSeek Harness'
}
if (isQoderTerminalTitle(title)) {
return 'Qoder CLI'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
@@ -250,6 +257,7 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
'Claude Code': 'claude',
OpenClaude: 'openclaude',
Codex: 'codex',
'Qoder CLI': 'qoder',
'Gemini CLI': 'gemini',
'GitHub Copilot': 'copilot',
Grok: 'grok',
+6 -1
View File
@@ -41,7 +41,7 @@ export type TuiAgentConfig = {
/** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */
pasteNeedsTypedRequest?: boolean
/** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */
preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity'
preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder'
/** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */
draftPasteReadySignal?: DraftPasteReadySignal
/** Hard deadline for the agent's composer readiness signal. */
@@ -184,6 +184,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
// Why: Prime Agent embeds Pi's TUI and decodes CSI-u the same way (see pi above).
windowsShiftEnterEncoding: 'csi-u'
},
qoder: {
detectCmd: 'qodercli',
promptInjectionMode: 'flag-prompt-interactive',
preflightTrust: 'qoder'
},
gemini: {
detectCmd: 'gemini',
promptInjectionMode: 'flag-prompt-interactive'
+1
View File
@@ -23,6 +23,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record<TuiAgent, string> = {
pi: 'Pi',
omp: 'OMP',
'prime-agent': 'Prime Agent',
qoder: 'Qoder CLI',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
+1
View File
@@ -8,6 +8,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
'claude-agent-teams': '--dangerously-skip-permissions',
openclaude: '--dangerously-skip-permissions',
codex: '--dangerously-bypass-approvals-and-sandbox',
qoder: '--dangerously-skip-permissions',
gemini: '--yolo',
antigravity: '--dangerously-skip-permissions',
aider: '--yes-always',
+1
View File
@@ -17,6 +17,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'trae',
'muse',
'dsh',
'qoder',
'zcode',
'pi',
'omp',
+1
View File
@@ -11,6 +11,7 @@ export type TuiAgent =
| 'mimo-code'
| 'pi' // Pi (pi.dev)
| 'omp' // OMP (omp.sh)
| 'qoder' // Qoder CLI
| 'gemini' // Gemini CLI
| 'antigravity' // Google Antigravity CLI
| 'aider' // Aider