From 3ef04055ee4b5b5b5613c15b326cc7e4447fbaca Mon Sep 17 00:00:00 2001 From: Xialie Zhuang <62231346+Lieisyourlie@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:22:57 +0800 Subject: [PATCH] feat(byoa): run agents on Qwen Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes #75's list. `qwen` was already in ENGINE_LABEL / ENGINE_BIN and ENGINE_VERSION_SPECS, so the Computers tab could report its version and Cumora still could not wake it. The adapter is thin for a reason worth stating: Qwen Code is a Gemini CLI fork and shares its flags (-o stream-json, -p, -r, -m), but it does NOT share Gemini's output. It emits Claude Code's envelope — {type:'assistant',session_id,message:{model,content,usage}} closing on {type:'result',subtype,is_error,num_turns,usage} — which is exactly the shape spawnEngine already sniffs. So the wake path needs no parser of its own, and gets session id, terminating usage, real model and one ledger hop per assistant message for free. An adapter derived from the Gemini one would have parsed nothing. That is also why triage needs the only new code here: `-o json` returns those same events wrapped in a JSON array rather than Claude's {result,usage} object, so there is no envelope to unwrap and the reply has to be assembled from the assistant text blocks. Triage runs --safe-mode, qwen's own switch for "ignore every customization": context files, hooks, extensions, skills and MCP servers stay unloaded. It leaves auth alone, and --yolo is an argv flag that still wins over it, so nothing can stall waiting for an approval nobody is there to give. Every claim above was checked against a real @qwen-code/qwen-code 0.22.3, including that `-y` genuinely sets approval mode (its help dropped the entry in the subcommand restructure, but the headless yolo warning fires only when getApprovalMode() === 'yolo', and it fires). Rebased onto the BYOA sandboxing work. Qwen lands in the compatibility tier, and needs no code to put it there: SANDBOXED_ENGINE_IDS is ['claude','codex'], so runnableEngineIds() already excludes qwen unless CUMORA_BYOA_ALLOW_UNSANDBOXED=1 is set. Verified — default resolves to claude, codex; with the opt-in, claude, codex, gemini, qwen. The docs table gains a Qwen column in that vocabulary, and the agent-cli prose is left as main rewrote it, since it deliberately stopped enumerating engines. byoa-qwen goes in the shared BYOA_SOURCES whitelist: normalizeByoaSource falls back to 'byoa-claude' for anything unknown, so omitting it would have attributed every qwen run's spend to Claude rather than failing loudly. The PAIRABLE record from #116 caught the same class of omission at compile time during this rebase, which is what it was added for. Also refreshes a redetect fixture that used 'qwen' as its example of an engine with no adapter — true until this commit. --- docs/BYOA.md | 33 +- scripts/guard-big-brain.mjs | 2 +- .../agents-computer-engine-qwen.test.ts | 314 ++++++++++++++++++ .../computer-engine-redetect.test.ts | 7 +- server/src/agents/computer/cli-version.ts | 4 +- server/src/agents/computer/engine.ts | 190 ++++++++++- server/src/agents/computer/registry.ts | 3 +- server/src/agents/llm-ledger.ts | 2 +- server/src/agents/observability.ts | 2 +- server/src/agents/runtime/byoa-source.ts | 1 + server/src/api/router.ts | 2 +- server/src/db/migrate.ts | 6 +- src/admin/api.ts | 2 +- src/api/client.ts | 2 +- src/lib/engines.ts | 2 +- src/types.ts | 2 +- 16 files changed, 542 insertions(+), 32 deletions(-) create mode 100644 server/src/__tests__/agents-computer-engine-qwen.test.ts diff --git a/docs/BYOA.md b/docs/BYOA.md index 395484d7..c0d1cde9 100644 --- a/docs/BYOA.md +++ b/docs/BYOA.md @@ -1,4 +1,4 @@ -# BYOA — Bring Your Own Agent (local Claude Code / Codex / Grok Build / Cursor Agent / OpenCode / pi / Gemini CLI as the engine) +# BYOA — Bring Your Own Agent (local Claude Code / Codex / Grok Build / Cursor Agent / OpenCode / pi / Gemini CLI / Qwen Code as the engine) Every Cumora agent has a "brain" and a host. The managed path is server-side: `runAgentTurn` in `server/src/agents/turn.ts` runs a @@ -8,7 +8,7 @@ a per-agent Kubernetes pod (the `agent-computer` image). **BYOA** lets a user supply the brain instead: a long-running daemon on the user's own machine (laptop **or** VPS) drives a local **Claude Code**, **Codex CLI**, **Grok Build** (`grok`), **Cursor Agent** (`cursor-agent`), -**OpenCode** (`opencode`), **pi** (`pi`), or **Gemini CLI** (`gemini`) as the reasoning engine, on the user's own provider +**OpenCode** (`opencode`), **pi** (`pi`), **Gemini CLI** (`gemini`), or **Qwen Code** (`qwen`) as the reasoning engine, on the user's own provider account — the server never holds the user's provider credentials. One daemon hosts **many independent agents** — each with its own dedicated home directory, memory, skills, and notes. In Cumora these still appear @@ -45,7 +45,7 @@ managed cloud agents and local agents into the same picture. user to set up; it's always online. - **Your computers** — machines you pair (your Mac, a VPS). Each runs the `cumora agent computer` daemon with a local engine (Claude Code / - Codex / Grok Build / Cursor Agent / OpenCode / pi / Gemini CLI). Agents you place here are BYOA agents. + Codex / Grok Build / Cursor Agent / OpenCode / pi / Gemini CLI / Qwen Code). Agents you place here are BYOA agents. ``` Computers @@ -194,13 +194,13 @@ from their own agenda — Kanban cards and due calendar slots — via ## Engine integration `server/src/agents/computer/engine.ts` defines one `EngineAdapter` per -engine (`claude`, `codex`, `grok`, `cursor`, `opencode`, `gemini`). Persistent per-agent +engine (`claude`, `codex`, `grok`, `cursor`, `opencode`, `gemini`, `qwen`). Persistent per-agent sessions are preferred when the CLI exposes one; Cursor and OpenCode use one-shot `run()` for every wake and resume the session id reported by the CLI. ```ts interface EngineAdapter { - id: 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'gemini' + id: 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'gemini' | 'qwen' seedHome(home, persona) // lay out CLAUDE.md/AGENTS.md, skills, dirs startSession?(args): EngineSession | null // persistent session (primary) run(args): Promise<…> // one-shot fallback @@ -215,16 +215,16 @@ interface EngineSession { } ``` -| Concern | Claude Code | Codex CLI | Grok Build | Cursor Agent | OpenCode | pi | Gemini CLI | -| --- | --- | --- | --- | --- | --- | --- | --- | -| Secure default | Claude Code ≥ 2.1.248 with `--restricted`; no Bash/PowerShell/web tools; host-home reads, command network, unsandboxed retry, and subprocess credentials are denied | Codex ≥ 0.138.0 with a custom permission profile: only minimal runtime reads plus the agent home; command network disabled; tool env allowlisted; user/project config, rules, hooks, apps, remote plugins, and multi-agent tools ignored or disabled | disabled | disabled | disabled | disabled | disabled | -| Platform | macOS, Linux, WSL2; native Windows disabled because Claude's sandbox is unsupported there | macOS, Linux, WSL2, native Windows | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | -| Persistent session | secure default; `claude -p --input-format stream-json --output-format stream-json --verbose` | compatibility opt-in only; secure default uses one-shot `exec` | compatibility opt-in ACP | none | none | compatibility opt-in RPC | none | -| Standing prompt | `--append-system-prompt-file /.cumora-standing-prompt.md` | inlined into each secure one-shot wake | compatibility ACP `_meta.rules` | inlined | inlined | compatibility `--append-system-prompt` | inlined | -| One-shot | sandboxed `claude -p … --output-format stream-json` | sandboxed `codex exec --ignore-user-config --ignore-rules …` | compatibility `grok -p … --always-approve` | compatibility `cursor-agent … --force --trust` | compatibility `opencode run … --auto` | compatibility `pi … -p` | compatibility `gemini … --yolo` | -| Custom argv | ignored securely; requires the compatibility opt-in | ignored securely; requires the compatibility opt-in | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | -| Memory / persona file | `CLAUDE.md` | `AGENTS.md` | `AGENTS.md` | `AGENTS.md` | `AGENTS.md` plus `.opencode/skills/` | `AGENTS.md` plus `.pi/skills/` (loaded via `--skill`) | `GEMINI.md` plus `.gemini/skills/` | -| Triage (small brain) | restricted and tool-free | read-only custom profile and tool env | compatibility only | compatibility only | compatibility only | compatibility only | compatibility only | +| Concern | Claude Code | Codex CLI | Grok Build | Cursor Agent | OpenCode | pi | Gemini CLI | Qwen Code | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Secure default | Claude Code ≥ 2.1.248 with `--restricted`; no Bash/PowerShell/web tools; host-home reads, command network, unsandboxed retry, and subprocess credentials are denied | Codex ≥ 0.138.0 with a custom permission profile: only minimal runtime reads plus the agent home; command network disabled; tool env allowlisted; user/project config, rules, hooks, apps, remote plugins, and multi-agent tools ignored or disabled | disabled | disabled | disabled | disabled | disabled | disabled | +| Platform | macOS, Linux, WSL2; native Windows disabled because Claude's sandbox is unsupported there | macOS, Linux, WSL2, native Windows | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | compatibility opt-in only | +| Persistent session | secure default; `claude -p --input-format stream-json --output-format stream-json --verbose` | compatibility opt-in only; secure default uses one-shot `exec` | compatibility opt-in ACP | none | none | compatibility opt-in RPC | none | none — 0.22.3 has no stream-json INPUT mode | +| Standing prompt | `--append-system-prompt-file /.cumora-standing-prompt.md` | inlined into each secure one-shot wake | compatibility ACP `_meta.rules` | inlined | inlined | compatibility `--append-system-prompt` | inlined | inlined | +| One-shot | sandboxed `claude -p … --output-format stream-json` | sandboxed `codex exec --ignore-user-config --ignore-rules …` | compatibility `grok -p … --always-approve` | compatibility `cursor-agent … --force --trust` | compatibility `opencode run … --auto` | compatibility `pi … -p` | compatibility `gemini … --yolo` | compatibility `qwen --output-format stream-json --yolo` | +| Custom argv | ignored securely; requires the compatibility opt-in | ignored securely; requires the compatibility opt-in | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | compatibility opt-in required | +| Memory / persona file | `CLAUDE.md` | `AGENTS.md` | `AGENTS.md` | `AGENTS.md` | `AGENTS.md` plus `.opencode/skills/` | `AGENTS.md` plus `.pi/skills/` (loaded via `--skill`) | `GEMINI.md` plus `.gemini/skills/` | `QWEN.md` plus `.qwen/skills/` | +| Triage (small brain) | restricted and tool-free | read-only custom profile and tool env | compatibility only | compatibility only | compatibility only | compatibility only | compatibility only | compatibility only | Sessions carry a resume id (`~/.cumora/sessions/.session`); a failed resume falls back to a fresh thread instead of wedging the agent. @@ -332,6 +332,7 @@ CUMORA_ENGINE_MODEL=local CUMORA_TRIAGE_MODEL=local-small cumora agent computer .cursor/skills/ ← Cursor-native skill directory .opencode/skills/ ← OpenCode-native skill directory .gemini/skills/ ← Gemini-native skill directory + .qwen/skills/ ← Qwen-native skill directory .pi/skills/ ← pi-native skill directory (via --skill) bin/cumora ← compatibility mode only memory/MEMORY.md ← the agent's durable memory index @@ -384,7 +385,7 @@ CREATE TABLE computers ( owner_user_id TEXT, -- null for the managed Cumora Cloud row name TEXT NOT NULL, -- "Cumora Cloud", "MacBook Pro", … kind TEXT NOT NULL, -- 'cloud' | 'local' | 'vps' - available_engines JSONB, -- ['claude','codex','grok','cursor','opencode','pi','gemini'] (daemon-detected) + available_engines JSONB, -- ['claude','codex','grok','cursor','opencode','pi','gemini','qwen'] (daemon-detected) status TEXT NOT NULL, -- 'online' | 'offline' | 'busy' last_seen_at TIMESTAMP, credential_hash TEXT, -- SHA256 of the device token diff --git a/scripts/guard-big-brain.mjs b/scripts/guard-big-brain.mjs index 7bb4ba0d..a29b1818 100644 --- a/scripts/guard-big-brain.mjs +++ b/scripts/guard-big-brain.mjs @@ -80,7 +80,7 @@ export function lineViolations(rel, raw) { out.push('big-brain engine spawn (adapter.run) outside the gated daemon path') } // R4 — a supported engine binary spawned directly, outside the adapter. - if (/(?:spawn|execFile\w*|exec)\s*\(\s*['"`](claude|codex|grok|cursor-agent|opencode|pi|gemini)['"`]/.test(code) && !ALLOW.engineSpawn.includes(rel)) { + if (/(?:spawn|execFile\w*|exec)\s*\(\s*['"`](claude|codex|grok|cursor-agent|opencode|pi|gemini|qwen)['"`]/.test(code) && !ALLOW.engineSpawn.includes(rel)) { out.push('engine binary spawned outside the engine adapter (bypasses the classify=small / run=big split)') } return out diff --git a/server/src/__tests__/agents-computer-engine-qwen.test.ts b/server/src/__tests__/agents-computer-engine-qwen.test.ts new file mode 100644 index 00000000..7372df6f --- /dev/null +++ b/server/src/__tests__/agents-computer-engine-qwen.test.ts @@ -0,0 +1,314 @@ +/** + * Contract tests for the BYOA Qwen Code adapter. + * + * Qwen is not installed in CI. Each test puts a fake `qwen` first on PATH and + * replays what `qwen --output-format stream-json` actually emits — Claude + * Code's envelope, captured from a real @qwen-code/qwen-code 0.22.3 run. + * + * That is the point worth pinning. Qwen Code is a Gemini CLI fork and shares + * its flags, so the tempting assumption is that it shares Gemini's output too. + * It does not: Gemini emits `{type,timestamp,status,stats}` and Qwen emits + * `{type:'result',subtype,is_error,num_turns,permission_denials,usage}`. An + * adapter derived from the other one would parse nothing. + * + * Run: node --import tsx --test server/src/__tests__/agents-computer-engine-qwen.test.ts + */ +import { existsSync } from 'node:fs' +import { chmod, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, test } from 'node:test' +import assert from 'node:assert/strict' +import { detectEngines, getAdapter, qwenReplyFromStream, type EngineHopReport } from '../agents/computer/engine.js' + +const IS_WIN = process.platform === 'win32' +const ORIGINAL_PATH = process.env.PATH +const tempDirs: string[] = [] + +afterEach(async () => { + delete process.env.CUMORA_QWEN_ARGS + delete process.env.CUMORA_TRIAGE_ARGS + delete process.env.CUMORA_TRIAGE_MODEL + process.env.PATH = ORIGINAL_PATH + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +const FAKE_QWEN = `#!/usr/bin/env node +'use strict' +const fs = require('node:fs') +const argv = process.argv.slice(2) +const scenario = process.env.FAKE_QWEN_SCENARIO || 'ok' +let stdin = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => { stdin += chunk }) +process.stdin.on('end', () => { + const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null } + const sid = at('--resume') || '9c29376e-3a32-4271-bbe8-462395aa8199' + const model = at('--model') || 'qwen3-coder-plus' + if (process.env.FAKE_QWEN_LOG) { + fs.appendFileSync(process.env.FAKE_QWEN_LOG, JSON.stringify({ + argv, stdin, cwd: process.cwd(), + }) + '\\n') + } + const out = (event) => process.stdout.write(JSON.stringify({ session_id: sid, ...event }) + '\\n') + + if (scenario === 'no-auth') { + // Verbatim shape of a real 0.22.3 failure before the first API call. + out({ + type: 'result', subtype: 'error_during_execution', uuid: 'f8b5c7ad', + is_error: true, duration_ms: 0, duration_api_ms: 0, num_turns: 0, + usage: { input_tokens: 0, output_tokens: 0 }, permission_denials: [], + error: { message: 'No auth type is selected. Please configure an auth type (e.g. via settings or --auth-type) before running in non-interactive mode.' }, + }) + process.exitCode = 1 + return + } + + if (scenario === 'two-hops') { + out({ + type: 'assistant', uuid: 'm1', + message: { id: 'm1', type: 'message', role: 'assistant', model, + content: [{ type: 'tool_use', id: 't1', name: 'run_shell_command', input: {} }], + stop_reason: 'tool_use', usage: { input_tokens: 40, output_tokens: 5 } }, + }) + out({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }) + } + + out({ + type: 'assistant', uuid: 'm2', + message: { id: 'm2', type: 'message', role: 'assistant', model, + content: [{ type: 'text', text: 'echo:' + stdin.trim() }], + stop_reason: null, usage: { input_tokens: 100, output_tokens: 12 } }, + }) + out({ + type: 'result', subtype: 'success', uuid: 'r1', is_error: false, + duration_ms: 1234, duration_api_ms: 1000, num_turns: 1, + usage: { input_tokens: 100, output_tokens: 12, cache_read_input_tokens: 40 }, + permission_denials: [], + }) +}) +` + +interface Fixture { root: string; home: string; log: string; env: NodeJS.ProcessEnv } + +async function fixture(scenario = 'ok', extraEnv: NodeJS.ProcessEnv = {}): Promise { + const root = await mkdtemp(join(tmpdir(), 'cumora-qwen-')) + tempDirs.push(root) + const binDir = join(root, 'bin') + const home = join(root, 'home') + const log = join(root, 'fake.log') + await mkdir(binDir) + await mkdir(home) + const fake = join(binDir, 'qwen') + await writeFile(fake, FAKE_QWEN, 'utf8') + await chmod(fake, 0o755) + return { + root, home, log, + env: { + ...process.env, ...extraEnv, + PATH: `${binDir}${delimiter}${ORIGINAL_PATH ?? ''}`, + FAKE_QWEN_LOG: log, + FAKE_QWEN_SCENARIO: scenario, + }, + } +} + +async function fakeLog(f: Fixture): Promise> { + if (!existsSync(f.log)) return [] + return (await readFile(f.log, 'utf8')).split('\n').filter(Boolean).map((l) => JSON.parse(l)) +} + +const noop = { onLog: () => {}, signal: new AbortController().signal } + +// ── the turn ──────────────────────────────────────────────────────────────── + +test('qwen run reads the Claude-shaped envelope without a parser of its own', { skip: IS_WIN }, async () => { + // The whole reason this adapter is thin: spawnEngine already lifts session + // id, terminating usage and model out of exactly this shape. + const f = await fixture() + const res = await getAdapter('qwen').run({ + home: f.home, prompt: 'hello there', env: f.env, model: 'qwen3-coder-plus', ...noop, + }) + + assert.equal(res.exitCode, 0) + assert.equal(res.error, undefined) + assert.equal(res.sessionId, '9c29376e-3a32-4271-bbe8-462395aa8199') + assert.equal(res.model, 'qwen3-coder-plus') + assert.deepEqual(res.usage, { input_tokens: 100, output_tokens: 12, cache_read_input_tokens: 40 }) + + const [call] = await fakeLog(f) + assert.equal(call.stdin, 'hello there') + assert.ok(!call.argv.includes('hello there'), 'the prompt must not ride in argv') + assert.deepEqual(call.argv.slice(0, 2), ['--output-format', 'stream-json']) + assert.ok(call.argv.includes('--yolo')) + assert.equal(call.cwd, await realpath(f.home)) +}) + +test('every assistant message is reported as its own hop', { skip: IS_WIN }, async () => { + const hops: EngineHopReport[] = [] + const f = await fixture('two-hops') + await getAdapter('qwen').run({ + home: f.home, prompt: 'x', env: f.env, onHopUsage: (h) => hops.push(h), ...noop, + }) + assert.equal(hops.length, 2) + assert.deepEqual(hops[0].usage, { input_tokens: 40, output_tokens: 5 }) + assert.equal(hops[0].toolUses, 1) + assert.deepEqual(hops[1].usage, { input_tokens: 100, output_tokens: 12 }) + assert.equal(hops[1].hopIndex, 2) +}) + +test('qwen run resumes by session id, not by "most recent"', { skip: IS_WIN }, async () => { + // -c/--continue resumes the newest session for the project, which is wrong + // here: one machine runs many agents out of many homes. + const f = await fixture() + const res = await getAdapter('qwen').run({ + home: f.home, prompt: 'again', env: f.env, resumeSessionId: 'sess-42', ...noop, + }) + const [call] = await fakeLog(f) + assert.equal(call.argv[call.argv.indexOf('--resume') + 1], 'sess-42') + assert.ok(!call.argv.includes('--continue') && !call.argv.includes('-c')) + assert.equal(res.sessionId, 'sess-42') +}) + +test('a failed turn surfaces the reason', { skip: IS_WIN }, async () => { + const f = await fixture('no-auth') + const res = await getAdapter('qwen').run({ home: f.home, prompt: 'x', env: f.env, ...noop }) + assert.notEqual(res.exitCode, 0) + assert.match(res.error ?? '', /No auth type is selected/) +}) + +// ── the reply parser ──────────────────────────────────────────────────────── + +test('the reply is assembled from assistant text blocks', () => { + // Qwen has no Claude-style `{result,usage}` object to unwrap — `-o json` is + // just these same events in an array — so triage has to concatenate. + const stream = [ + '{"type":"assistant","session_id":"s1","message":{"model":"qwen3-coder-plus","content":[{"type":"text","text":"Hello, "}],"usage":{"input_tokens":1,"output_tokens":1}}}', + '{"type":"assistant","session_id":"s1","message":{"model":"qwen3-coder-plus","content":[{"type":"text","text":"world"}],"usage":{"input_tokens":1,"output_tokens":1}}}', + '{"type":"result","subtype":"success","session_id":"s1","is_error":false,"usage":{"input_tokens":9,"output_tokens":3}}', + ].join('\n') + const r = qwenReplyFromStream(stream) + assert.equal(r.text, 'Hello, world') + assert.deepEqual(r.usage, { input_tokens: 9, output_tokens: 3 }) + assert.equal(r.model, 'qwen3-coder-plus') + assert.equal(r.error, undefined) +}) + +test('a tool_use block contributes no text', () => { + // Only `type: 'text'` blocks are the reply. Folding a tool_use's JSON input + // into the answer would hand triage machine structure to classify. + const stream = '{"type":"assistant","message":{"model":"m","content":[' + + '{"type":"tool_use","id":"t","name":"run_shell_command","input":{"command":"ls"}},' + + '{"type":"text","text":"done"}]}}' + assert.equal(qwenReplyFromStream(stream).text, 'done') +}) + +test('a failed result is reported even with text already streamed', () => { + const stream = [ + '{"type":"assistant","message":{"model":"m","content":[{"type":"text","text":"partial"}]}}', + '{"type":"result","subtype":"error_during_execution","is_error":true,"usage":{"input_tokens":0,"output_tokens":0},"error":{"message":"Quota exceeded"}}', + ].join('\n') + const r = qwenReplyFromStream(stream) + assert.equal(r.text, 'partial') + assert.equal(r.error, 'Quota exceeded') +}) + +test('a failed result with no detail still reports something', () => { + const r = qwenReplyFromStream('{"type":"result","is_error":true}') + assert.match(r.error ?? '', /failed turn/) +}) + +test('non-JSON noise and truncated lines are ignored', () => { + // Startup banners share stdout, and a capture can be cut mid-object. + const stream = [ + 'Warning: running headless with --yolo and no sandbox.', + '{"type":"assistant","message":{"model":"m","content":[{"type":"text","text":"ok"}]}}', + '{"type":"result","is_error":fal', + ].join('\n') + const r = qwenReplyFromStream(stream) + assert.equal(r.text, 'ok') + assert.equal(r.error, undefined) +}) + +// ── triage ────────────────────────────────────────────────────────────────── + +test('triage runs safe-mode on the cheap model and gets the text back', { skip: IS_WIN }, async () => { + const f = await fixture() + const res = await getAdapter('qwen').classify({ cwd: f.home, prompt: 'classify this', env: f.env, ...noop }) + assert.equal(res.text, 'echo:classify this') + assert.deepEqual(res.usage, { input_tokens: 100, output_tokens: 12, cache_read_input_tokens: 40 }) + + const [call] = await fakeLog(f) + // --safe-mode leaves context files, hooks, extensions, skills and MCP + // servers unloaded; --yolo is an argv flag and still wins over it, so + // nothing can stall on an approval nobody is there to give. + assert.ok(call.argv.includes('--safe-mode')) + assert.ok(call.argv.includes('--yolo')) + assert.equal(call.argv[call.argv.indexOf('--model') + 1], 'qwen3-coder-flash') +}) + +test('CUMORA_TRIAGE_MODEL is what triage AND the small probe run on', { skip: IS_WIN }, async () => { + // If they diverged, doctor would report a red small brain for an operator + // whose triage is configured correctly. + process.env.CUMORA_TRIAGE_MODEL = 'qwen3-coder-30b' + const f = await fixture() + await getAdapter('qwen').classify({ cwd: f.home, prompt: 'x', env: f.env, ...noop }) + await getAdapter('qwen').probe({ tier: 'small', cwd: f.home, env: f.env, signal: noop.signal }) + const calls = await fakeLog(f) + assert.equal(calls.length, 2) + for (const c of calls) assert.equal(c.argv[c.argv.indexOf('--model') + 1], 'qwen3-coder-30b') +}) + +test('the big probe leaves the model to the operator', { skip: IS_WIN }, async () => { + // Qwen Code is multi-provider (its own OAuth, DashScope, any OpenAI- + // compatible base URL), so there is no cheap model id that is right for + // everyone — pinning one would fail operators who never configured it. + const f = await fixture() + await getAdapter('qwen').probe({ tier: 'big', cwd: f.home, env: f.env, signal: noop.signal }) + const [call] = await fakeLog(f) + assert.ok(!call.argv.includes('--model')) +}) + +test('the wake probe is skipped because the wake IS the one-shot path', { skip: IS_WIN }, async () => { + const res = await getAdapter('qwen').probeWake({ cwd: tmpdir(), env: process.env, signal: noop.signal }) + assert.deepEqual(res, { ok: true, detail: '', skipped: true }) +}) + +// ── home layout, overrides, detection ─────────────────────────────────────── + +test('qwen seedHome writes QWEN.md and its native skills directory', { skip: IS_WIN }, async () => { + const f = await fixture() + await getAdapter('qwen').seedHome(f.home, { + id: 'qwen', name: 'Wen', role: 'Analyst', systemPrompt: 'Analyse only.', + }) + const persona = await readFile(join(f.home, 'QWEN.md'), 'utf8') + assert.match(persona, /^# Wen — Analyst/) + assert.match(persona, /Analyse only\./) + assert.match(persona, /`QWEN\.md` \(this file\)/) + assert.match(persona, /`\.qwen\/skills\/` — your skills/) + assert.doesNotMatch(persona, /CLAUDE\.md|GEMINI\.md|AGENTS\.md/) + assert.ok(existsSync(join(f.home, 'memory', 'MEMORY.md'))) + assert.ok(existsSync(join(f.home, '.qwen', 'skills'))) +}) + +test('CUMORA_QWEN_ARGS takes over argv but keeps resume and the stdin prompt', { skip: IS_WIN }, async () => { + process.env.CUMORA_QWEN_ARGS = '--output-format json --debug' + const f = await fixture() + await getAdapter('qwen').run({ + home: f.home, prompt: 'override me', env: f.env, resumeSessionId: 'sess-9', ...noop, + }) + const [call] = await fakeLog(f) + assert.deepEqual(call.argv, ['--output-format', 'json', '--debug', '--resume', 'sess-9']) + assert.equal(call.stdin, 'override me') +}) + +test('qwen is detected on PATH', { skip: IS_WIN }, async () => { + const f = await fixture() + const before = process.env.PATH + process.env.PATH = f.env.PATH as string + try { + assert.ok((await detectEngines()).includes('qwen')) + } finally { + process.env.PATH = before + } +}) diff --git a/server/src/__tests__/computer-engine-redetect.test.ts b/server/src/__tests__/computer-engine-redetect.test.ts index c74d7c0e..35e67870 100644 --- a/server/src/__tests__/computer-engine-redetect.test.ts +++ b/server/src/__tests__/computer-engine-redetect.test.ts @@ -69,7 +69,12 @@ test('an unchanged empty list writes nothing', () => { test('a detection of only unknown ids is treated as empty', () => { // A newer daemon reporting an engine this server has no adapter for must not // clear the list either. - assert.equal(mergeDetectedEngines(['claude'], ['qwen', 'hermes']), null) + // + // The fixture has to be ids with NO adapter, so it goes stale every time one + // gains one — 'qwen' used to sit here and now drives real wakes. Replace the + // ids when that happens rather than deleting the case: what it protects is + // the compatibility path, not these particular names. + assert.equal(mergeDetectedEngines(['claude'], ['hermes', 'not-an-engine']), null) }) test('unknown ids are filtered out but known ones still apply', () => { diff --git a/server/src/agents/computer/cli-version.ts b/server/src/agents/computer/cli-version.ts index efedcd42..14c16cf0 100644 --- a/server/src/agents/computer/cli-version.ts +++ b/server/src/agents/computer/cli-version.ts @@ -70,7 +70,9 @@ export interface EngineVersionSpec { latestVia?: 'cursor-about' | 'grok-check' } -/** Keep in sync with electron/main.cjs LOCAL_CLIS and src/lib/engines.ts. */ +/** Keep in sync with src/lib/engines.ts (ENGINE_LABEL / ENGINE_BIN / + * RUNNABLE_ENGINE_IDS) and, for engines Cumora can actually wake, ENGINE_IDS + * in agents/computer/engine.ts. */ export const ENGINE_VERSION_SPECS: Record = { claude: { versionArgs: ['--version'], diff --git a/server/src/agents/computer/engine.ts b/server/src/agents/computer/engine.ts index 5c45c687..5b8939e6 100644 --- a/server/src/agents/computer/engine.ts +++ b/server/src/agents/computer/engine.ts @@ -317,10 +317,10 @@ export function resolveSpawn(bin: string): { command: string; shell: boolean; wa return { command: bin, shell: true, wantsStdinPrompt: true } } -export type EngineId = 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' +export type EngineId = 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' | 'qwen' /** The pairable engine ids, in the daemon's default detection order. */ -export const ENGINE_IDS: EngineId[] = ['claude', 'codex', 'grok', 'cursor', 'opencode', 'pi', 'gemini'] +export const ENGINE_IDS: EngineId[] = ['claude', 'codex', 'grok', 'cursor', 'opencode', 'pi', 'gemini', 'qwen'] /** Engines for which Cumora can impose a fail-closed filesystem + tool-network * boundary non-interactively. The remaining adapters still work for operators @@ -4343,6 +4343,191 @@ class GeminiAdapter implements EngineAdapter { // a fresh one, which is what the one-shot path already does. } +// ── Qwen Code ──────────────────────────────────────────────────────────────── +// +// Qwen Code is a Gemini CLI fork, and shares its flags (`-o stream-json`, +// `-p`, `-r`, `-m`) — but NOT its output. It emits Claude Code's envelope +// instead: `{type:'assistant',session_id,message:{model,content,usage}}` and a +// terminating `{type:'result',subtype,is_error,usage}`. That is the shape +// `spawnEngine` already sniffs, so the wake path needs no parser of its own; +// only triage does, because it needs the reply TEXT back. +// +// The two forks having the same flags and different envelopes is exactly why +// neither adapter is derived from the other. + +interface QwenBlock { type?: string; text?: unknown } +interface QwenEvent { + type?: string + session_id?: string + is_error?: boolean + error?: { message?: unknown } + usage?: EngineUsage + message?: { model?: unknown; usage?: EngineUsage; content?: unknown } +} + +/** Pull the assistant's reply out of a captured stream-json run. + * + * Exported for tests: it is pure, and it is the only part of this adapter + * that a fake binary cannot exercise through `spawnEngine`. + * + * Both `-o json` and `-o stream-json` are event streams here — `json` is + * simply the same events wrapped in a JSON array, NOT Claude's + * `{result,usage}` object — so there is no envelope to unwrap and the text + * has to be assembled from the assistant messages. */ +export function qwenReplyFromStream(stdout: string): { + text: string + usage?: EngineUsage + model?: string | null + error?: string +} { + let text = '' + let usage: EngineUsage | undefined + let model: string | null = null + let error: string | undefined + for (const raw of stdout.split('\n')) { + const line = raw.trim() + if (!line.startsWith('{')) continue + let ev: QwenEvent + try { ev = JSON.parse(line) as QwenEvent } catch { continue } + if (typeof ev.message?.model === 'string' && ev.message.model) model = ev.message.model + if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) { + for (const block of ev.message.content as QwenBlock[]) { + if (block?.type === 'text' && typeof block.text === 'string') text += block.text + } + continue + } + if (ev.type !== 'result') continue + if (ev.usage && typeof ev.usage === 'object') usage = ev.usage + if (ev.is_error) { + const detail = ev.error?.message + error = typeof detail === 'string' && detail ? detail : 'qwen reported a failed turn with no detail' + } + } + return { text: text.trim(), usage, model, error } +} + +class QwenAdapter implements EngineAdapter { + readonly id = 'qwen' as const + readonly bin = 'qwen' + + /** stream-json on both paths. `-o json` returns the same events as a JSON + * array, so it buys nothing here and only adds a second shape to parse. */ + private static readonly STREAM = ['--output-format', 'stream-json'] + + private async ask(prompt: string, args: { + cwd: string + env: NodeJS.ProcessEnv + signal: AbortSignal + onLog?: (line: string) => void + model?: string | null + }): Promise { + const { command, shell } = resolveSpawn(this.bin) + // No --model at all when the caller has none: the big-brain probe must + // exercise whatever the operator made default, and forcing the cheap + // triage id there would report on a model their wakes never use. + const model = args.model ? ['--model', args.model] : [] + const res = await spawnCapture( + command, + // `--safe-mode` is qwen's own switch for "ignore every customization": + // context files, hooks, extensions, skills and MCP servers all stay + // unloaded, which is the same thing --strict-mcp-config buys the Claude + // triage path and keeps a cold triage spawn cheap. It deliberately does + // NOT disable auth, and `-y` (an argv flag) still wins over it, so + // nothing can stall waiting for an approval nobody is there to give. + [...QwenAdapter.STREAM, '--safe-mode', '--yolo', ...model], + { + cwd: args.cwd, + env: args.env, + signal: args.signal, + onLog: args.onLog, + shell, + stdinText: prompt, + }, + ) + const parsed = qwenReplyFromStream(res.text) + return { + text: parsed.text, + // A process failure explains more than a stream that merely reported one, + // so keep the same precedence the other stream adapters use. + error: res.error ?? (parsed.error ? `engine turn error: ${parsed.error.slice(0, MAX_FAILURE_CHARS)}` : undefined), + usage: parsed.usage, + model: parsed.model ?? args.model ?? null, + } + } + + async classify(args: EngineClassifyArgs): Promise { + const flags = extraArgs('CUMORA_TRIAGE_ARGS') + if (flags.length) { + // Whole user-owned override: the output becomes opaque, but the prompt + // still travels on stdin so a long prompt and a Windows shim survive. + const { command, shell } = resolveSpawn(this.bin) + return spawnCapture(command, flags, { + cwd: args.cwd, env: args.env, signal: args.signal, onLog: args.onLog, shell, stdinText: args.prompt, + }) + } + return this.ask(args.prompt, { + cwd: args.cwd, env: args.env, signal: args.signal, onLog: args.onLog, + model: args.model || triageModel('qwen3-coder-flash'), + }) + } + + async probe(args: EngineProbeArgs): Promise { + // Qwen Code is multi-provider (its own OAuth, DashScope, or any OpenAI- + // compatible base URL), so there is no cheap model id that is right for + // every operator. The small tier honours CUMORA_TRIAGE_MODEL — the same + // knob triage reads, so doctor cannot report a red small brain for an + // operator whose triage is configured correctly — and the big tier uses + // whatever the operator made default. + const model = args.tier === 'small' ? triageModel('qwen3-coder-flash') : null + return this.ask(DOCTOR_PROMPT, { cwd: args.cwd, env: args.env, signal: args.signal, model }) + } + + probeWake(_args: EngineWakeProbeArgs): Promise { + // The wake is the same one-shot process probe() already exercises. + // --resume re-opens a conversation inside it, it is not a second protocol. + return Promise.resolve({ ok: true, detail: '', skipped: true }) + } + + async seedHome(home: string, persona: EnginePersona): Promise { + await ensureCommonHome(home) + await mkdir(join(home, '.qwen', 'skills'), { recursive: true }) + await writeFile( + join(home, 'QWEN.md'), + PERSONA_HEADER(persona, { personaFile: 'QWEN.md', skillsDir: '.qwen/skills/' }), + 'utf8', + ) + } + + async run(args: EngineRunArgs): Promise { + const flags = extraArgs('CUMORA_QWEN_ARGS') + const { command, shell } = resolveSpawn(this.bin) + // `--resume ` resumes by session id (`-c` resumes the newest, which is + // wrong here: one machine runs many agents out of many homes). + const resume = args.resumeSessionId ? ['--resume', args.resumeSessionId] : [] + if (flags.length) { + return spawnEngine(command, [...flags, ...resume], args, { shell, stdinText: args.prompt }) + } + const model = args.model ? ['--model', args.model] : [] + // No tracker: the envelope is Claude's, so spawnEngine already lifts the + // session id, the terminating usage, the model, and one hop per assistant + // message into the ledger. + return spawnEngine( + command, + [...QwenAdapter.STREAM, '--yolo', ...resume, ...model], + args, + // Prompt on stdin on every platform: `qwen` reads it there when no -p is + // given, which avoids argv limits and the Windows .cmd shim's inability + // to carry a multi-line argument. + { shell, stdinText: args.prompt }, + ) + } + + // No startSession: qwen has no stream-json INPUT mode, so there is no way to + // feed turn N+1 into a live process. --resume re-opens the conversation in a + // fresh one, which is what the one-shot path already does. +} + + const ADAPTERS: Record = { claude: new ClaudeAdapter(), codex: new CodexAdapter(), @@ -4351,6 +4536,7 @@ const ADAPTERS: Record = { opencode: new OpenCodeAdapter(), pi: new PiAdapter(), gemini: new GeminiAdapter(), + qwen: new QwenAdapter(), } export function getAdapter(id: EngineId): EngineAdapter { diff --git a/server/src/agents/computer/registry.ts b/server/src/agents/computer/registry.ts index 74375780..d686ebf7 100644 --- a/server/src/agents/computer/registry.ts +++ b/server/src/agents/computer/registry.ts @@ -20,7 +20,7 @@ import { CH_STATUS, publish } from '../../redis.js' import { signAgentToken } from '../runtime/jwt.js' export type ComputerKind = 'cloud' | 'local' | 'vps' -export type EngineId = 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' +export type EngineId = 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' | 'qwen' export type ComputerStatus = 'online' | 'offline' | 'busy' /** How long a paired computer can go without a heartbeat before the sweep @@ -58,6 +58,7 @@ export async function announceComputerOnline(computerId: string, companyId: stri * left out of it could be detected and shown but never actually paired. */ const PAIRABLE: Record, true> = { claude: true, codex: true, grok: true, cursor: true, opencode: true, pi: true, gemini: true, + qwen: true, } const PAIRABLE_ENGINES: ReadonlySet = new Set(Object.keys(PAIRABLE)) diff --git a/server/src/agents/llm-ledger.ts b/server/src/agents/llm-ledger.ts index b914b2c4..f9dc7631 100644 --- a/server/src/agents/llm-ledger.ts +++ b/server/src/agents/llm-ledger.ts @@ -75,7 +75,7 @@ export type LlmCallPurpose = | 'agent-image' export type LlmCallStatus = 'ok' | 'rate_limited' | 'timeout' | 'failed' -export type LlmCallSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' +export type LlmCallSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' | 'byoa-qwen' /** Context bound to a tracked client. Every LLM call it makes carries this * shape into the ledger. `purpose` is mandatory; everything else scopes the diff --git a/server/src/agents/observability.ts b/server/src/agents/observability.ts index c550df53..b284fb1f 100644 --- a/server/src/agents/observability.ts +++ b/server/src/agents/observability.ts @@ -4,7 +4,7 @@ import { pool } from '../db/pool.js' import { EMPTY_USAGE, effectiveCostUsd, modelPriceTable, priceFor, type TokenUsage } from './cost.js' export type AgentRunStatus = 'running' | 'completed' | 'failed' | 'skipped' -export type TriageSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' +export type TriageSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' | 'byoa-qwen' export type AgentEventLevel = 'debug' | 'info' | 'warn' | 'error' type Queryable = Pick diff --git a/server/src/agents/runtime/byoa-source.ts b/server/src/agents/runtime/byoa-source.ts index b5643958..2c2356ed 100644 --- a/server/src/agents/runtime/byoa-source.ts +++ b/server/src/agents/runtime/byoa-source.ts @@ -8,6 +8,7 @@ export const BYOA_SOURCES = [ 'byoa-opencode', 'byoa-pi', 'byoa-gemini', + 'byoa-qwen', ] as const export type ByoaSource = typeof BYOA_SOURCES[number] diff --git a/server/src/api/router.ts b/server/src/api/router.ts index f1c4919e..0c785913 100644 --- a/server/src/api/router.ts +++ b/server/src/api/router.ts @@ -1204,7 +1204,7 @@ api.post('/computers/pair', safe(async (req, res) => { const engine = ( engines[0] === 'claude' || engines[0] === 'codex' || engines[0] === 'grok' || engines[0] === 'cursor' || engines[0] === 'opencode' || engines[0] === 'pi' || - engines[0] === 'gemini' + engines[0] === 'gemini' || engines[0] === 'qwen' ) ? engines[0] : 'claude' // Adopt only agents that are stranded on the managed Cumora Cloud (or // unassigned) onto the just-paired machine — earlier builds' boot backfill diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 6f93600d..b02e35b3 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -220,7 +220,7 @@ CREATE TABLE IF NOT EXISTS agent_triages ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, company_id TEXT, - source TEXT NOT NULL, -- cloud | byoa-claude | byoa-codex | byoa-grok | byoa-cursor | byoa-opencode | byoa-pi | byoa-gemini + source TEXT NOT NULL, -- cloud | byoa-claude | byoa-codex | byoa-grok | byoa-cursor | byoa-opencode | byoa-pi | byoa-gemini | byoa-qwen model TEXT, actionable BOOLEAN NOT NULL DEFAULT FALSE, -- verdict: woke the big brain? reason TEXT, @@ -1504,7 +1504,7 @@ CREATE TABLE IF NOT EXISTS computers ( owner_user_id TEXT, -- NULL for the managed Cumora Cloud row name TEXT NOT NULL, -- "Cumora Cloud", "MacBook Pro", "prod-vps-01" kind TEXT NOT NULL, -- 'cloud' | 'local' | 'vps' - available_engines JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['claude','codex','grok','cursor','opencode','pi','gemini']; ['managed'] for cloud + available_engines JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['claude','codex','grok','cursor','opencode','pi','gemini','qwen']; ['managed'] for cloud status TEXT NOT NULL DEFAULT 'offline', -- 'online' | 'offline' | 'busy' last_seen_at TIMESTAMP WITH TIME ZONE, credential_hash TEXT, -- SHA256 of the device token; NULL for cloud @@ -1526,7 +1526,7 @@ ALTER TABLE computers ADD COLUMN IF NOT EXISTS daemon_supervised BOOLEAN; -- a 'cloud' computer, means managed (current pod behavior). A 'local' / -- 'vps' computer means BYOA: wakes go to the paired daemon, no pod. ALTER TABLE participants ADD COLUMN IF NOT EXISTS computer_id TEXT; -ALTER TABLE participants ADD COLUMN IF NOT EXISTS engine TEXT; -- 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' +ALTER TABLE participants ADD COLUMN IF NOT EXISTS engine TEXT; -- 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' | 'qwen' -- Per-agent model overrides. "model" (added earlier) is the big-brain / main -- reasoning model; "fast_model" is the small-brain model for cheap auxiliary -- work. For BYOA agents these pass through to the engine as --model (big) and, diff --git a/src/admin/api.ts b/src/admin/api.ts index 224dfcee..d650050e 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -120,7 +120,7 @@ export type LlmCallPurpose = | 'palette' | 'gender' | 'avatar-image' | 'agent-image' export type LlmCallStatus = 'ok' | 'rate_limited' | 'timeout' | 'failed' -export type LlmCallSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' +export type LlmCallSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' | 'byoa-qwen' export interface LlmSummary { sinceDays: number diff --git a/src/api/client.ts b/src/api/client.ts index aabce221..bd85f16e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -404,7 +404,7 @@ export interface ApiAgentRun { } // ── Triage cost-effectiveness ledger ── -export type ApiTriageSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' +export type ApiTriageSource = 'cloud' | 'byoa-claude' | 'byoa-codex' | 'byoa-grok' | 'byoa-cursor' | 'byoa-opencode' | 'byoa-pi' | 'byoa-gemini' | 'byoa-qwen' export interface ApiTriageAgentRow { agentId: string diff --git a/src/lib/engines.ts b/src/lib/engines.ts index 41aa4ec4..4eedb842 100644 --- a/src/lib/engines.ts +++ b/src/lib/engines.ts @@ -33,7 +33,7 @@ export const ENGINE_BIN: Record = { * state that holds a choice, and the set for membership tests. Engine pickers * used to inline their own copy of this list *and* their own copy of the * labels, which is how adding an engine could leave it unselectable. */ -export const RUNNABLE_ENGINES = ['claude', 'codex', 'grok', 'cursor', 'opencode', 'pi', 'gemini'] as const +export const RUNNABLE_ENGINES = ['claude', 'codex', 'grok', 'cursor', 'opencode', 'pi', 'gemini', 'qwen'] as const export type RunnableEngineId = typeof RUNNABLE_ENGINES[number] diff --git a/src/types.ts b/src/types.ts index 763e1fac..073b7233 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ export type Status = 'avail' | 'working' | 'thinking' | 'waiting' | 'resting' export type ComputerKind = 'cloud' | 'local' | 'vps' export type ComputerStatus = 'online' | 'offline' | 'busy' /** Engine an agent's host runs it on. 'managed' = Cumora's server-side loop. */ -export type EngineId = 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' +export type EngineId = 'managed' | 'claude' | 'codex' | 'grok' | 'cursor' | 'opencode' | 'pi' | 'gemini' | 'qwen' export interface Computer { id: string