mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
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:
co-authored by
dalveytech-vincent
Eridanus117
xingqingzzp-gif
jyang2004
yunqian
huzhening.hzn
parent
08300512eb
commit
8b410b4893
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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: {} }))
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 @@
|
||||
[8m[?u]11;?\[>q[>4;?m[c[2K␍[0m]0;◇ Qoder CLI | Ready ]8;;[0m7[r8[J␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
[H[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h[0m␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m██████[39m[38G╭─[41G[38;5;78m[1mTips[22m[39m[46G[38;5;78m[1mfor[22m[39m[50G[38;5;78m[1mgetting[22m[39m[58G[38;5;78m[1mstarted[22m[39m[66G────────────────────────────────╮␍
|
||||
[2G[38;5;78m██[39m[10G[38;5;78m██[39m[38G│[40G1.[43GUse[47GAGENTS.md[57Gfiles[63Gto[66Gcustomize[76Ginteractions[98G│␍
|
||||
[2G[38;5;78m██[39m[6G[38;5;78m██[39m[10G[38;5;78m██[39m[14G[1mQoder[22m[20G[1mCLI[22m[24Gv1.1.64[38G│[40G2.[43G/help[49Gfor[53Gmore[58Ginformation[98G│␍
|
||||
[2G[38;5;78m██[39m[8G[38;5;78m██[39m[38G│[40G3.[43GAsk[47Gcoding[54Gquestions,[65Gedit[70Gcode[75Gor[78Grun[82Gcommands[98G│␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m████[39m[10G[38;5;78m██[39m[14G[1mNot[22m[18G[1mLogin[22m[24GPlease[31GAuth[38G│[40G4.[43GBe[46Gspecific[55Gfor[59Gthe[63Gbest[68Gresults[98G│␍
|
||||
[38G╰───────────────────────────────────────────────────────────╯␍
|
||||
␍
|
||||
␍
|
||||
[1m Authenticating[22m␍
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
␍
|
||||
[2G⠋[4GWaiting[12Gfor[16Gauthentication...␍
|
||||
␍
|
||||
[2G[3mEsc[23m[6G[3mback[23m␍
|
||||
[0m[>0q[c[0m␍[1C[6A[1mW[22m[1me[22m[1ml[22m[1mc[22m[1mo[22m[1mm[22m[1me[22m[1m [22m[1mt[22m[1mo[22m[1m [22m[1mQ[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m [22m[1mC[22m[1mL[22m[1mI[22m␍[1C[2BSign[7Gin[10Gto[13Gget[17Gstarted,[26Gor[29Gexit[34Gthe[38Gapplication.␍[1C[1B[K␍[2C[1B❯[5G1.[8GSign[13Gin[16Gto[19Gcontinue␍[1C[1B 2. Exit[13Gthe[17Gapplication␍
|
||||
␍
|
||||
[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m␍
|
||||
[0m]0;✦ Qoder CLI | Working [0m␍[8A > Reply with hello.[K␍[1B[K␍[1B[1m [22m[1mW[22m[1me[22m[1ml[22m[1mc[22m[1mo[22m[1mm[22m[1me[22m[1m [22m[1mt[22m[1mo[22m[1m [22m[1mQ[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m [22m[1mC[22m[1mL[22m[1mI[22m[K␍[1B────────────────────────────────────────────────────────────────────────────────────────────────────␍[1C[1BSign in to[13Gget started, or[29Gexit[34Gthe[38Gapplication.␍[4C[1B[K␍[2C[1B❯[5G1.[8GSign[13Gin[16Gto[19Gcontinue␍[1C[1B 2. Exit the application[K␍
|
||||
␍
|
||||
[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m␍
|
||||
[0m]0;✦ Reply with hello. | Working ]0;◇ Reply with hello. | Ready [0m␍[8A x Qoder authentication[25Gis[28Gnot[32Gready.[39GPlease[46Gsign[51Gin[54Gagain[60G(e.g.[66Gvia[70G/login)[78Gbefore[85Gsending[93Ga␍[1B message.[K␍[1B[1m [22m[1mW[22m[1me[22m[1ml[22m[1mc[22m[1mo[22m[1mm[22m[1me[22m[1m [22m[1mt[22m[1mo[22m[1m [22m[1mQ[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m [22m[1mC[22m[1mL[22m[1mI[22m[K␍[1B────────────────────────────────────────────────────────────────────────────────────────────────────␍[1C[1BSign in to[13Gget started, or[29Gexit[34Gthe[38Gapplication.␍[4C[1B[K␍[2C[1B❯[5G1.[8GSign[13Gin[16Gto[19Gcontinue␍[1C[1B 2. Exit the application[K␍
|
||||
␍
|
||||
[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m␍
|
||||
[0m[0m␍[3C[9A[K␍
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[0m
|
||||
@@ -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 @@
|
||||
[8m[?u]11;?\[>q[>4;?m[c[2K␍[0m]0;◇ Qoder CLI | Ready ]8;;[0m7[r8[J␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
[H[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h[0m␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m██████[39m[40G╭─[43G[38;5;78m[1mTips[22m[39m[48G[38;5;78m[1mfor[22m[39m[52G[38;5;78m[1mgetting[22m[39m[60G[38;5;78m[1mstarted[22m[39m[68G──────────────────────────────╮␍
|
||||
[2G[38;5;78m██[39m[10G[38;5;78m██[39m[40G│[42G1.[45GUse[49GAGENTS.md[59Gfiles[65Gto[68Gcustomize[78Ginteractions[98G│␍
|
||||
[2G[38;5;78m██[39m[6G[38;5;78m██[39m[10G[38;5;78m██[39m[14G[1mQoder[22m[20G[1mCLI[22m[24Gv1.1.64[40G│[42G2.[45G/help[51Gfor[55Gmore[60Ginformation[98G│␍
|
||||
[2G[38;5;78m██[39m[8G[38;5;78m██[39m[40G│[42G3.[45GAsk[49Gcoding[56Gquestions,[67Gedit[72Gcode[77Gor[80Grun[84Gcommands[98G│␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m████[39m[10G[38;5;78m██[39m[14G[1mSigned[22m[21G[1min[22m[24GBrowser[32GLogin[40G│[42G4.[45GBe[48Gspecific[57Gfor[61Gthe[65Gbest[70Gresults[98G│␍
|
||||
[40G╰─────────────────────────────────────────────────────────╯␍
|
||||
␍
|
||||
␍
|
||||
[85G?[87Gfor[91Gshortcuts␍
|
||||
[2m────────────────────────────────────────────────────────────────────────────────────────────────────[22m␍
|
||||
[2G[38;5;247mShift+Tab[39m[12G[38;5;247mto[39m[15G[38;5;247mAccept[39m[22G[38;5;247mEdits[39m␍
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
[2G>[4G[7m [27m[6GType[11Gyour[16Gmessage[24Gor[27G@path/to/file␍
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
[3GModel[9G·[11Gctx[15G░░░░░░░░░░[26G0%[29G·[31G/private/tmp/orca-qoder-probe/workspace␍
|
||||
[3C[3A[0m[>0q[c[0m[3D[3B␍[1C[1AQwen3.8-Max Model · ctx[26G░░░░░░░░░░ 0% · /priv[48Gte/tmp/o[57Gca-qoder-[67Grobe/workspace␍
|
||||
[3C[3A[0m[0m[3D[3B␍[1C[7A●[4GWarning:[13GTrue[18Gcolor[24G(24-bit)[33Gsupport[41Gnot[45Gdetected.[55GUsing[61Ga[63Gterminal[72Gwith[77Gtrue[82Gcolor enabled will␍[1B result in a better visual experience.[K␍[1C[1B[K␍[1B ? for shortcuts[K␍[1B[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m[2m─[22m␍[1B [38;5;247mS[39m[38;5;247mh[39m[38;5;247mi[39m[38;5;247mf[39m[38;5;247mt[39m[38;5;247m+[39m[38;5;247mT[39m[38;5;247ma[39m[38;5;247mb[39m[38;5;247m [39m[38;5;247mt[39m[38;5;247mo[39m[38;5;247m [39m[38;5;247mA[39m[38;5;247mc[39m[38;5;247mc[39m[38;5;247me[39m[38;5;247mp[39m[38;5;247mt[39m[38;5;247m [39m[38;5;247mE[39m[38;5;247md[39m[38;5;247mi[39m[38;5;247mt[39m[38;5;247ms[39m 1 MCP server[K␍[1B────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
[2G>[4G[7m [27m[6GType[11Gyour[16Gmessage[24Gor[27G@path/to/file␍
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
[2GQwen3.8-Max[14GModel[20G·[22Gctx[26G░░░░░░░░░░[37G0%[40G·[42G/private/tmp/orca-qoder-probe/workspace␍
|
||||
[3C[3A[0m[0m[3D[3B␍[75C[5A1[78GMCP[82Gserver · 34[95Gkills␍
|
||||
|
||||
|
||||
|
||||
|
||||
[3C[3A[0m[0m[3D[3B␍[36C[7ACredits[45Gexhausted.[56GUse[60G/usage[67Gfor[71Gdetails[79Gor[82G/upgrade[91Gfor more.␍
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[3C[3A[0m
|
||||
@@ -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 @@
|
||||
[8m[?u]11;?\[>q[>4;?m[c[2K␍[0m]0;◇ Qoder CLI | Ready ]8;;[0m7[r8[J␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
[H[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h[0m␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m██████[39m[38G╭─[41G[38;5;78m[1mTips[22m[39m[46G[38;5;78m[1mfor[22m[39m[50G[38;5;78m[1mgetting[22m[39m[58G[38;5;78m[1mstarted[22m[39m[66G────────────────────────────────╮␍
|
||||
[2G[38;5;78m██[39m[10G[38;5;78m██[39m[38G│[40G1.[43GUse[47GAGENTS.md[57Gfiles[63Gto[66Gcustomize[76Ginteractions[98G│␍
|
||||
[2G[38;5;78m██[39m[6G[38;5;78m██[39m[10G[38;5;78m██[39m[14G[1mQoder[22m[20G[1mCLI[22m[24Gv1.1.64[38G│[40G2.[43G/help[49Gfor[53Gmore[58Ginformation[98G│␍
|
||||
[2G[38;5;78m██[39m[8G[38;5;78m██[39m[38G│[40G3.[43GAsk[47Gcoding[54Gquestions,[65Gedit[70Gcode[75Gor[78Grun[82Gcommands[98G│␍
|
||||
[2G[38;5;78m [39m[4G[38;5;78m████[39m[10G[38;5;78m██[39m[14G[1mNot[22m[18G[1mLogin[22m[24GPlease[31GAuth[38G│[40G4.[43GBe[46Gspecific[55Gfor[59Gthe[63Gbest[68Gresults[98G│␍
|
||||
[38G╰───────────────────────────────────────────────────────────╯␍
|
||||
␍
|
||||
␍
|
||||
[1m Do[22m[5G[1myou[22m[9G[1mtrust[22m[15G[1mthe[22m[19G[1mfiles[22m[25G[1min[22m[28G[1mthis[22m[33G[1mfolder?[22m␍
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────␍
|
||||
[2GPlease[9Gconfirm[17Gthis[22Gis[25Gyour[30Gown[34Gproject[42Gor[45Gfrom[50Ga[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92Gcan␍
|
||||
[2Gread,[8Gmodify,[16Gand[20Gexecute[28Gfiles[34Ghere,[40Gand[44Gwill[49Gload[54Glocal[60Gconfigurations[75G(commands,[86Ghooks,[93GMCP␍
|
||||
[2Gservers,[11Gagents,[19Gskills,[27Gsettings).␍
|
||||
␍
|
||||
[2G[1m/private/tmp/orca-qoder-probe/workspace[22m␍
|
||||
␍
|
||||
[3G❯[5G1.[8GTrust[14Gfolder␍
|
||||
[5G2.[8GDon't[14Gtrust[20Gand[24Gexit␍
|
||||
␍
|
||||
[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m␍
|
||||
[0m[>0q[c[0m␍[12A ● Warning: True color (24-bit) support[41Gnot[45Gdetected.[55GUsing[61Ga[63Gterminal[72Gwith[77Gtrue[82Gcolor[88Genabled[96Gwill␍[1B result in a better visual experience.[K␍[1C[1B[K␍[1B[1m [22m[1mD[22m[1mo[22m[1m [22m[1my[22m[1mo[22m[1mu[22m[1m [22m[1mt[22m[1mr[22m[1mu[22m[1ms[22m[1mt[22m[1m [22m[1mt[22m[1mh[22m[1me[22m[1m [22m[1mf[22m[1mi[22m[1ml[22m[1me[22m[1ms[22m[1m [22m[1mi[22m[1mn[22m[1m [22m[1mt[22m[1mh[22m[1mi[22m[1ms[22m[1m [22m[1mf[22m[1mo[22m[1ml[22m[1md[22m[1me[22m[1mr[22m[1m?[22m[K␍[1B────────────────────────────────────────────────────────────────────────────────────────────────────␍[1C[1BPlease[9Gconfirm[17Gthis[22Gis[25Gyour[30Gown[34Gproject[42Gor[45Gfrom[50Ga[52Gtrusted[60Gsource.[68GOnce[73Gtrusted,[82GQoder[88GCLI[92Gcan␍[1C[1Bread, modify, and execute files here, and[44Gwill[49Gload[54Glocal[60Gconfigurations[75G(commands,[86Ghooks,[93GMCP␍[1C[1Bservers,[11Gagents,[19Gskills,[27Gsettings).␍[2C[1B[K␍[1C[1B[1m/[22m[1mp[22m[1mr[22m[1mi[22m[1mv[22m[1ma[22m[1mt[22m[1me[22m[1m/[22m[1mt[22m[1mm[22m[1mp[22m[1m/[22m[1mo[22m[1mr[22m[1mc[22m[1ma[22m[1m-[22m[1mq[22m[1mo[22m[1md[22m[1me[22m[1mr[22m[1m-[22m[1mp[22m[1mr[22m[1mo[22m[1mb[22m[1me[22m[1m/[22m[1mw[22m[1mo[22m[1mr[22m[1mk[22m[1ms[22m[1mp[22m[1ma[22m[1mc[22m[1me[22m␍[1C[2B ❯ 1. Trust folder[K␍
|
||||
[5G2.[8GDon't[14Gtrust[20Gand[24Gexit␍
|
||||
␍
|
||||
[2G[3m↑/↓[23m[6G[3mnavigate[23m[15G[3m·[23m[17G[3mEnter[23m[23G[3mselect[23m[30G[3m·[23m[32G[3mEsc[23m[36G[3mback[23m␍
|
||||
[0m
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
@@ -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',
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { AgentHookTarget } from './agent-hook-types'
|
||||
const AGENT_HOOK_SOURCES = [
|
||||
'claude',
|
||||
'codex',
|
||||
'qoder',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'amp',
|
||||
|
||||
@@ -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 |
@@ -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',
|
||||
|
||||
@@ -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-'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const AGENT_KIND_VALUES = [
|
||||
'pi',
|
||||
'omp',
|
||||
'prime-agent',
|
||||
'qoder',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'aider',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
|
||||
'trae',
|
||||
'muse',
|
||||
'dsh',
|
||||
'qoder',
|
||||
'zcode',
|
||||
'pi',
|
||||
'omp',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user