Compare commits
2 Commits
677d41074b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf926e327f | |||
| 7ab826ac7e |
46
README.md
46
README.md
@@ -94,6 +94,7 @@ npm start
|
|||||||
{
|
{
|
||||||
"id": "codex-full",
|
"id": "codex-full",
|
||||||
"label": "Codex Full Permission",
|
"label": "Codex Full Permission",
|
||||||
|
"engine": "codex",
|
||||||
"command": "codex",
|
"command": "codex",
|
||||||
"args": [
|
"args": [
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
@@ -103,6 +104,32 @@ npm start
|
|||||||
"--cd",
|
"--cd",
|
||||||
"{workspace_path}"
|
"{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 历史
|
## 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
|
```bash
|
||||||
|
# codex
|
||||||
codex --dangerously-bypass-approvals-and-sandbox exec --sandbox danger-full-access --cd <workspace> resume <session_id> <prompt>
|
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]` 行。
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ let state = {
|
|||||||
selectedRepoId: null,
|
selectedRepoId: null,
|
||||||
selectedBranch: null,
|
selectedBranch: null,
|
||||||
expandedGpuHosts: new Set(),
|
expandedGpuHosts: new Set(),
|
||||||
|
expandedTasks: new Set(),
|
||||||
|
expandedTerminals: new Set(),
|
||||||
settings: { gpuHosts: [] },
|
settings: { gpuHosts: [] },
|
||||||
errors: {}
|
errors: {}
|
||||||
};
|
};
|
||||||
@@ -72,7 +74,12 @@ function shortSession(sessionId) {
|
|||||||
|
|
||||||
function isGpuIdle(gpu) {
|
function isGpuIdle(gpu) {
|
||||||
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
return memoryPercent < 5 && gpu.gpuUtilizationPercent < 5;
|
return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGpuAbnormal(gpu) {
|
||||||
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
|
return memoryPercent >= 5 && gpu.gpuUtilizationPercent < 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toast(message, tone = '') {
|
function toast(message, tone = '') {
|
||||||
@@ -169,8 +176,8 @@ function renderGpus() {
|
|||||||
const expanded = state.expandedGpuHosts.has(host.host);
|
const expanded = state.expandedGpuHosts.has(host.host);
|
||||||
const thumbRows = host.gpus.map((gpu) => {
|
const thumbRows = host.gpus.map((gpu) => {
|
||||||
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
const busy = !isGpuIdle(gpu);
|
const thumbClass = isGpuAbnormal(gpu) ? 'abnormal' : isGpuIdle(gpu) ? 'idle' : 'busy';
|
||||||
return `<span class="gpu-thumb ${busy ? 'busy' : 'idle'}" title="#${gpu.index} GPU ${gpu.gpuUtilizationPercent}% · MEM ${memoryPercent}%"></span>`;
|
return `<span class="gpu-thumb ${thumbClass}" title="#${gpu.index} GPU ${gpu.gpuUtilizationPercent}% · MEM ${memoryPercent}%"></span>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const gpuRows = host.gpus.map((gpu) => {
|
const gpuRows = host.gpus.map((gpu) => {
|
||||||
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
@@ -299,12 +306,13 @@ function renderResumeTasks() {
|
|||||||
$('resumeTaskButton').disabled = options.length === 0;
|
$('resumeTaskButton').disabled = options.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTerminal(label, content, tone = '', expanded = true) {
|
function renderTerminal(label, content, tone = '', expanded = true, terminalKey = '') {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
const lineCount = content.split('\n').length;
|
const lineCount = content.split('\n').length;
|
||||||
const body = `<pre class="terminal-body" tabindex="0">${escapeHtml(content)}</pre>`;
|
const body = `<pre class="terminal-body" tabindex="0">${escapeHtml(content)}</pre>`;
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return `<details class="terminal ${tone}">
|
const openAttr = terminalKey && state.expandedTerminals.has(terminalKey) ? ' open' : '';
|
||||||
|
return `<details class="terminal ${tone}" data-terminal-key="${escapeHtml(terminalKey)}"${openAttr}>
|
||||||
<summary class="terminal-head">
|
<summary class="terminal-head">
|
||||||
<span>${label}</span>
|
<span>${label}</span>
|
||||||
<span>${lineCount} lines</span>
|
<span>${lineCount} lines</span>
|
||||||
@@ -343,8 +351,8 @@ function taskFullBody(task, { expandedTerminals }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="task-meta workspace-path">${escapeHtml(task.workspace)}</div>
|
<div class="task-meta workspace-path">${escapeHtml(task.workspace)}</div>
|
||||||
<div class="task-prompt">${escapeHtml(task.prompt)}</div>
|
<div class="task-prompt">${escapeHtml(task.prompt)}</div>
|
||||||
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals)}
|
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals, `${task.id}:stderr`)}
|
||||||
${renderTerminal('stdout', task.stdout, '', expandedTerminals)}
|
${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +368,8 @@ function renderHistoryTaskCard(task) {
|
|||||||
const resumeButton = canResumeTask(task)
|
const resumeButton = canResumeTask(task)
|
||||||
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
|
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
|
||||||
: '';
|
: '';
|
||||||
return `<details class="task-card history-task status-${escapeHtml(task.status)}">
|
const openAttr = state.expandedTasks.has(task.id) ? ' open' : '';
|
||||||
|
return `<details class="task-card history-task status-${escapeHtml(task.status)}" data-task-id="${escapeHtml(task.id)}"${openAttr}>
|
||||||
<summary class="task-summary">
|
<summary class="task-summary">
|
||||||
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
|
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
|
||||||
<span class="task-summary-main">
|
<span class="task-summary-main">
|
||||||
@@ -577,6 +586,24 @@ $('taskBoard').addEventListener('click', (event) => {
|
|||||||
selectResumeTask(button.dataset.resumeTask);
|
selectResumeTask(button.dataset.resumeTask);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('taskBoard').addEventListener('toggle', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLDetailsElement)) return;
|
||||||
|
if (target.matches('details.history-task')) {
|
||||||
|
const id = target.dataset.taskId;
|
||||||
|
if (!id) return;
|
||||||
|
if (target.open) state.expandedTasks.add(id);
|
||||||
|
else state.expandedTasks.delete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.matches('details.terminal')) {
|
||||||
|
const key = target.dataset.terminalKey;
|
||||||
|
if (!key) return;
|
||||||
|
if (target.open) state.expandedTerminals.add(key);
|
||||||
|
else state.expandedTerminals.delete(key);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
async function submitTask(resume) {
|
async function submitTask(resume) {
|
||||||
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
|
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
|
||||||
const prompt = $('promptInput').value.trim();
|
const prompt = $('promptInput').value.trim();
|
||||||
@@ -625,4 +652,4 @@ $('taskForm').addEventListener('submit', async (event) => {
|
|||||||
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
|
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 30000);
|
setInterval(refresh, 60000);
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ h3 {
|
|||||||
border-color: color-mix(in srgb, var(--good) 45%, transparent);
|
border-color: color-mix(in srgb, var(--good) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpu-thumb.abnormal {
|
||||||
|
background: color-mix(in srgb, var(--warn) 22%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.gpu-thumb.busy {
|
.gpu-thumb.busy {
|
||||||
background: color-mix(in srgb, var(--bad) 22%, transparent);
|
background: color-mix(in srgb, var(--bad) 22%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--bad) 50%, transparent);
|
border-color: color-mix(in srgb, var(--bad) 50%, transparent);
|
||||||
|
|||||||
@@ -58,11 +58,46 @@ function ensureCodexFullPermission(profile) {
|
|||||||
nextArgs.splice(execIndex, 0, '--dangerously-bypass-approvals-and-sandbox');
|
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('--sandbox')) nextArgs.splice(execIndex + 2, 0, '--sandbox', 'danger-full-access');
|
||||||
if (!nextArgs.includes('--cd')) nextArgs.push('--cd', '{workspace_path}');
|
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) {
|
function normalizeAgentProfiles(profiles) {
|
||||||
return profiles.map(ensureCodexFullPermission);
|
return profiles.map(normalizeAgentProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig() {
|
export function loadConfig() {
|
||||||
@@ -94,6 +129,7 @@ export function loadConfig() {
|
|||||||
{
|
{
|
||||||
id: 'codex-full',
|
id: 'codex-full',
|
||||||
label: 'Codex Full Permission',
|
label: 'Codex Full Permission',
|
||||||
|
engine: 'codex',
|
||||||
command: 'codex',
|
command: 'codex',
|
||||||
args: [
|
args: [
|
||||||
'--dangerously-bypass-approvals-and-sandbox',
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
@@ -103,6 +139,32 @@ export function loadConfig() {
|
|||||||
'--cd',
|
'--cd',
|
||||||
'{workspace_path}'
|
'{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?.();
|
persistTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSessionId(text) {
|
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;
|
||||||
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);
|
|
||||||
|
function parseCodexSessionId(text) {
|
||||||
|
const match = String(text || '').match(new RegExp(`session id:\\s*(${SESSION_ID_REGEX.source})`, 'i'));
|
||||||
return match ? match[1] : null;
|
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) {
|
export function listAgentProfiles(config) {
|
||||||
return config.agentProfiles || [];
|
return config.agentProfiles || [];
|
||||||
}
|
}
|
||||||
@@ -74,6 +160,17 @@ function expandArgs(args, values) {
|
|||||||
.replaceAll('{branch}', values.branch));
|
.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) {
|
export async function startAgentTask(config, payload) {
|
||||||
loadTasks(config);
|
loadTasks(config);
|
||||||
const profile = (config.agentProfiles || []).find((item) => item.id === payload.profileId);
|
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 isResume = Boolean(payload.resumeTaskId);
|
||||||
const previous = isResume ? tasks.get(payload.resumeTaskId) : null;
|
const previous = isResume ? tasks.get(payload.resumeTaskId) : null;
|
||||||
if (isResume && !previous?.sessionId) {
|
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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -100,15 +197,15 @@ export async function startAgentTask(config, payload) {
|
|||||||
repoFullName: payload.repo.fullName,
|
repoFullName: payload.repo.fullName,
|
||||||
branch: payload.branch
|
branch: payload.branch
|
||||||
});
|
});
|
||||||
const args = isResume
|
const engine = profile.engine || (profile.command === 'claude' ? 'claude' : profile.command === 'qodercli' ? 'qoder' : 'codex');
|
||||||
? [...baseArgs, 'resume', previous.sessionId, payload.prompt]
|
const args = buildAgentArgs(engine, baseArgs, payload.prompt, isResume ? previous.sessionId : null);
|
||||||
: [...baseArgs, payload.prompt];
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const task = {
|
const task = {
|
||||||
id,
|
id,
|
||||||
parentTaskId: previous?.id || null,
|
parentTaskId: previous?.id || null,
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
profileLabel: profile.label || profile.id,
|
profileLabel: profile.label || profile.id,
|
||||||
|
engine,
|
||||||
repoFullName: payload.repo.fullName,
|
repoFullName: payload.repo.fullName,
|
||||||
branch: payload.branch,
|
branch: payload.branch,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -134,26 +231,39 @@ export async function startAgentTask(config, payload) {
|
|||||||
|
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
task.stdout = `${task.stdout}${text}`.slice(-20000);
|
if (isStreamJsonEngine(engine)) {
|
||||||
task.sessionId ||= parseSessionId(text) || parseSessionId(task.stdout);
|
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);
|
persistTasks(config);
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
task.stderr = `${task.stderr}${text}`.slice(-20000);
|
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);
|
persistTasks(config);
|
||||||
});
|
});
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
task.status = 'failed';
|
task.status = 'failed';
|
||||||
task.stderr = `${task.stderr}\n${error.message}`.trim();
|
task.stderr = `${task.stderr}\n${error.message}`.trim();
|
||||||
task.finishedAt = new Date().toISOString();
|
task.finishedAt = new Date().toISOString();
|
||||||
|
claudeBuffers.delete(task.id);
|
||||||
persistTasks(config, { immediate: true });
|
persistTasks(config, { immediate: true });
|
||||||
});
|
});
|
||||||
child.on('close', (code) => {
|
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.status = code === 0 ? 'completed' : 'failed';
|
||||||
task.exitCode = code;
|
task.exitCode = code;
|
||||||
task.sessionId ||= parseSessionId(task.stdout) || parseSessionId(task.stderr);
|
|
||||||
task.finishedAt = new Date().toISOString();
|
task.finishedAt = new Date().toISOString();
|
||||||
persistTasks(config, { immediate: true });
|
persistTasks(config, { immediate: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user