From 7ab826ac7e487dd48c52680d6e0ae209b1267ae3 Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Fri, 15 May 2026 15:08:28 +0800 Subject: [PATCH] feat: add Claude Code and Qoder agent engines - Introduce `engine` field on agent profiles (codex/claude/qoder); auto-inferred from command when omitted. - Default profiles now include `claude-bypass` (mirrors `ccx='claude --permission-mode bypassPermissions'`) and `qoder-yolo` (mirrors `qxx='qodercli --yolo'`), both running headless with `-p --output-format stream-json` so session ids can be extracted for resume. - Stream-json engines share parsing: session_id pulled from `system.init`, task stdout shows only assistant text plus a `[done]` line, tool_use/tool_result noise dropped. - Resume builds `--resume ` for stream-json engines, keeps Codex's `resume ` positional form. Co-Authored-By: Claude Opus 4.7 --- README.md | 46 ++++++++++++++- src/config.js | 66 ++++++++++++++++++++- src/services/agents.js | 130 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 228 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f09c9ee..2c514f5 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ npm start { "id": "codex-full", "label": "Codex Full Permission", + "engine": "codex", "command": "codex", "args": [ "--dangerously-bypass-approvals-and-sandbox", @@ -103,6 +104,32 @@ npm start "--cd", "{workspace_path}" ] + }, + { + "id": "claude-bypass", + "label": "Claude Code Bypass", + "engine": "claude", + "command": "claude", + "args": [ + "--permission-mode", + "bypassPermissions", + "-p", + "--output-format", + "stream-json", + "--verbose" + ] + }, + { + "id": "qoder-yolo", + "label": "Qoder YOLO", + "engine": "qoder", + "command": "qodercli", + "args": [ + "--yolo", + "-p", + "--output-format", + "stream-json" + ] } ] } @@ -132,10 +159,25 @@ IPADS_API_KEY=replace-with-your-ipads-api-key ## Agent Session 历史 -任务历史会保存到 `~/.config/local-kanban/state.json`。每次 Codex 启动后,服务会从输出中提取 `session id` 并记录到对应的 project/branch。之后在同一个 project/branch 下,可以在 Web UI 的 “历史 Session” 下拉框里选择之前的任务,点击 “继续 Session” 发送新指令。 +任务历史会保存到 `~/.config/local-kanban/state.json`。每次 agent 启动后,服务会从输出中提取 `session id` 并记录到对应的 project/branch。之后在同一个 project/branch 下,可以在 Web UI 的 “历史 Session” 下拉框里选择之前的任务,点击 “继续 Session” 发送新指令。 -继续会话使用: +默认内置三个 agent profile,可在 Agent 配置下拉框里切换: + +- `codex-full` — Codex,完全权限模式 +- `claude-bypass` — Claude Code,等价于本地 `alias ccx='claude --permission-mode bypassPermissions'`,但跑在 headless 模式下(`-p --output-format stream-json --verbose`),方便提取 `session_id` +- `qoder-yolo` — Qoder CLI,等价于本地 `alias qxx='qodercli --yolo'`,headless 模式下加 `-p --output-format stream-json` + +继续会话使用(按 engine): ```bash +# codex codex --dangerously-bypass-approvals-and-sandbox exec --sandbox danger-full-access --cd resume + +# claude +claude --permission-mode bypassPermissions -p --output-format stream-json --verbose --resume + +# qoder +qodercli --yolo -p --output-format stream-json --resume ``` + +Profile 自定义时可设 `engine: "codex"` / `"claude"` / `"qoder"` 来选择参数构造与 session id 解析方式;省略时根据 `command` 推断。Claude 和 Qoder 的 stream-json 输出 schema 一致,前端展示时都只渲染 assistant 文本和最终 `[done]` 行。 diff --git a/src/config.js b/src/config.js index 3ae9491..ee6c4bd 100644 --- a/src/config.js +++ b/src/config.js @@ -58,11 +58,46 @@ function ensureCodexFullPermission(profile) { nextArgs.splice(execIndex, 0, '--dangerously-bypass-approvals-and-sandbox'); if (!nextArgs.includes('--sandbox')) nextArgs.splice(execIndex + 2, 0, '--sandbox', 'danger-full-access'); if (!nextArgs.includes('--cd')) nextArgs.push('--cd', '{workspace_path}'); - return { ...profile, args: nextArgs }; + return { ...profile, engine: 'codex', args: nextArgs }; +} + +function ensureClaudeBypass(profile) { + if (profile.engine !== 'claude' && profile.command !== 'claude') return profile; + const args = [...(profile.args || [])]; + if (!args.includes('--permission-mode')) args.push('--permission-mode', 'bypassPermissions'); + if (!args.includes('-p') && !args.includes('--print')) args.push('-p'); + if (!args.includes('--output-format')) args.push('--output-format', 'stream-json'); + if (!args.includes('--verbose')) args.push('--verbose'); + return { ...profile, engine: 'claude', command: profile.command || 'claude', args }; +} + +function ensureQoderYolo(profile) { + if (profile.engine !== 'qoder' && profile.command !== 'qodercli') return profile; + const args = [...(profile.args || [])]; + if (!args.includes('--yolo') && !args.includes('--dangerously-skip-permissions')) args.push('--yolo'); + if (!args.includes('-p') && !args.includes('--print')) args.push('-p'); + if (!args.includes('--output-format') && !args.includes('-o') && !args.includes('-f')) { + args.push('--output-format', 'stream-json'); + } + return { ...profile, engine: 'qoder', command: profile.command || 'qodercli', args }; +} + +function inferEngine(profile) { + if (profile.engine) return profile.engine; + if (profile.command === 'claude') return 'claude'; + if (profile.command === 'qodercli') return 'qoder'; + return 'codex'; +} + +function normalizeAgentProfile(profile) { + const engine = inferEngine(profile); + if (engine === 'claude') return ensureClaudeBypass({ ...profile, engine }); + if (engine === 'qoder') return ensureQoderYolo({ ...profile, engine }); + return ensureCodexFullPermission({ ...profile, engine }); } function normalizeAgentProfiles(profiles) { - return profiles.map(ensureCodexFullPermission); + return profiles.map(normalizeAgentProfile); } export function loadConfig() { @@ -94,6 +129,7 @@ export function loadConfig() { { id: 'codex-full', label: 'Codex Full Permission', + engine: 'codex', command: 'codex', args: [ '--dangerously-bypass-approvals-and-sandbox', @@ -103,6 +139,32 @@ export function loadConfig() { '--cd', '{workspace_path}' ] + }, + { + id: 'claude-bypass', + label: 'Claude Code Bypass', + engine: 'claude', + command: 'claude', + args: [ + '--permission-mode', + 'bypassPermissions', + '-p', + '--output-format', + 'stream-json', + '--verbose' + ] + }, + { + id: 'qoder-yolo', + label: 'Qoder YOLO', + engine: 'qoder', + command: 'qodercli', + args: [ + '--yolo', + '-p', + '--output-format', + 'stream-json' + ] } ]) }; diff --git a/src/services/agents.js b/src/services/agents.js index bfc7bb7..4fc938e 100644 --- a/src/services/agents.js +++ b/src/services/agents.js @@ -53,11 +53,97 @@ function persistTasks(config, { immediate = false } = {}) { persistTimer.unref?.(); } -function parseSessionId(text) { - const match = String(text || '').match(/session id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); +const SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +function parseCodexSessionId(text) { + const match = String(text || '').match(new RegExp(`session id:\\s*(${SESSION_ID_REGEX.source})`, 'i')); return match ? match[1] : null; } +function parseClaudeSessionId(text) { + for (const line of String(text || '').split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + try { + const obj = JSON.parse(trimmed); + if (obj && typeof obj.session_id === 'string' && SESSION_ID_REGEX.test(obj.session_id)) { + return obj.session_id; + } + } catch {} + } + return null; +} + +function isStreamJsonEngine(engine) { + return engine === 'claude' || engine === 'qoder'; +} + +function parseSessionId(engine, text) { + if (isStreamJsonEngine(engine)) return parseClaudeSessionId(text); + return parseCodexSessionId(text); +} + +const claudeBuffers = new Map(); + +function renderClaudeEvent(obj, task) { + if (!obj || typeof obj !== 'object') return null; + if (obj.type === 'system' && obj.subtype === 'init') { + if (obj.session_id && !task.sessionId) task.sessionId = obj.session_id; + return null; + } + if (obj.type === 'assistant') { + const content = obj.message?.content || []; + const texts = content + .filter((item) => item?.type === 'text' && item.text) + .map((item) => item.text); + if (!texts.length) return null; + const sep = task.stdout && !task.stdout.endsWith('\n') ? '\n\n' : ''; + return `${sep}${texts.join('\n')}`; + } + if (obj.type === 'result') { + const status = obj.is_error ? 'error' : 'ok'; + const dur = Number.isFinite(obj.duration_ms) ? ` · ${(obj.duration_ms / 1000).toFixed(1)}s` : ''; + const cost = Number.isFinite(obj.total_cost_usd) ? ` · $${obj.total_cost_usd.toFixed(4)}` : ''; + const sep = task.stdout && !task.stdout.endsWith('\n') ? '\n\n' : '\n'; + return `${sep}[done · ${status}${dur}${cost}]`; + } + return null; +} + +function processClaudeChunk(task, text) { + const buffered = (claudeBuffers.get(task.id) || '') + text; + const lines = buffered.split('\n'); + claudeBuffers.set(task.id, lines.pop() ?? ''); + let appended = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (!trimmed.startsWith('{')) { appended += `${line}\n`; continue; } + try { + const rendered = renderClaudeEvent(JSON.parse(trimmed), task); + if (rendered) appended += rendered; + } catch { + appended += `${line}\n`; + } + } + return appended; +} + +function flushClaudeBuffer(task) { + const remainder = claudeBuffers.get(task.id); + claudeBuffers.delete(task.id); + if (!remainder) return ''; + const trimmed = remainder.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('{')) { + try { + const rendered = renderClaudeEvent(JSON.parse(trimmed), task); + return rendered || ''; + } catch { return `${remainder}\n`; } + } + return `${remainder}\n`; +} + export function listAgentProfiles(config) { return config.agentProfiles || []; } @@ -74,6 +160,17 @@ function expandArgs(args, values) { .replaceAll('{branch}', values.branch)); } +function buildAgentArgs(engine, baseArgs, prompt, sessionId) { + if (isStreamJsonEngine(engine)) { + const args = [...baseArgs]; + if (sessionId) args.push('--resume', sessionId); + args.push(prompt); + return args; + } + if (sessionId) return [...baseArgs, 'resume', sessionId, prompt]; + return [...baseArgs, prompt]; +} + export async function startAgentTask(config, payload) { loadTasks(config); const profile = (config.agentProfiles || []).find((item) => item.id === payload.profileId); @@ -91,7 +188,7 @@ export async function startAgentTask(config, payload) { const isResume = Boolean(payload.resumeTaskId); const previous = isResume ? tasks.get(payload.resumeTaskId) : null; if (isResume && !previous?.sessionId) { - const error = new Error('Selected task does not have a recorded Codex session id.'); + const error = new Error('Selected task does not have a recorded session id.'); error.statusCode = 400; throw error; } @@ -100,15 +197,15 @@ export async function startAgentTask(config, payload) { repoFullName: payload.repo.fullName, branch: payload.branch }); - const args = isResume - ? [...baseArgs, 'resume', previous.sessionId, payload.prompt] - : [...baseArgs, payload.prompt]; + const engine = profile.engine || (profile.command === 'claude' ? 'claude' : profile.command === 'qodercli' ? 'qoder' : 'codex'); + const args = buildAgentArgs(engine, baseArgs, payload.prompt, isResume ? previous.sessionId : null); const id = randomUUID(); const task = { id, parentTaskId: previous?.id || null, profileId: profile.id, profileLabel: profile.label || profile.id, + engine, repoFullName: payload.repo.fullName, branch: payload.branch, workspace, @@ -134,26 +231,39 @@ export async function startAgentTask(config, payload) { child.stdout.on('data', (chunk) => { const text = chunk.toString(); - task.stdout = `${task.stdout}${text}`.slice(-20000); - task.sessionId ||= parseSessionId(text) || parseSessionId(task.stdout); + if (isStreamJsonEngine(engine)) { + const rendered = processClaudeChunk(task, text); + if (rendered) task.stdout = `${task.stdout}${rendered}`.slice(-20000); + } else { + task.stdout = `${task.stdout}${text}`.slice(-20000); + task.sessionId ||= parseSessionId(engine, text) || parseSessionId(engine, task.stdout); + } persistTasks(config); }); child.stderr.on('data', (chunk) => { const text = chunk.toString(); task.stderr = `${task.stderr}${text}`.slice(-20000); - task.sessionId ||= parseSessionId(text) || parseSessionId(task.stderr); + if (!isStreamJsonEngine(engine)) { + task.sessionId ||= parseSessionId(engine, text) || parseSessionId(engine, task.stderr); + } persistTasks(config); }); child.on('error', (error) => { task.status = 'failed'; task.stderr = `${task.stderr}\n${error.message}`.trim(); task.finishedAt = new Date().toISOString(); + claudeBuffers.delete(task.id); persistTasks(config, { immediate: true }); }); child.on('close', (code) => { + if (isStreamJsonEngine(engine)) { + const tail = flushClaudeBuffer(task); + if (tail) task.stdout = `${task.stdout}${tail}`.slice(-20000); + } else { + task.sessionId ||= parseSessionId(engine, task.stdout) || parseSessionId(engine, task.stderr); + } task.status = code === 0 ? 'completed' : 'failed'; task.exitCode = code; - task.sessionId ||= parseSessionId(task.stdout) || parseSessionId(task.stderr); task.finishedAt = new Date().toISOString(); persistTasks(config, { immediate: true }); });