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:
46
README.md
46
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 <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]` 行。
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user