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