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 <sid> <prompt>` for stream-json engines, keeps Codex's `resume <sid> <prompt>` positional form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:08:28 +08:00
parent 677d41074b
commit 7ab826ac7e
3 changed files with 228 additions and 14 deletions

View File

@@ -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 <workspace> resume <session_id> <prompt>
# claude
claude --permission-mode bypassPermissions -p --output-format stream-json --verbose --resume <session_id> <prompt>
# qoder
qodercli --yolo -p --output-format stream-json --resume <session_id> <prompt>
```
Profile 自定义时可设 `engine: "codex"` / `"claude"` / `"qoder"` 来选择参数构造与 session id 解析方式;省略时根据 `command` 推断。Claude 和 Qoder 的 stream-json 输出 schema 一致,前端展示时都只渲染 assistant 文本和最终 `[done]` 行。

View File

@@ -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'
]
}
])
};

View File

@@ -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();
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(text) || parseSessionId(task.stdout);
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 });
});