feat: support codex app goal command
This commit is contained in:
103
public/app.js
103
public/app.js
@@ -2,7 +2,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const ASSET_VERSION = '20260616-child-agent-close-state';
|
||||
const ASSET_VERSION = '20260617-codexapp-approval';
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||
const RENDER_DEBOUNCE = 100;
|
||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||
@@ -14,6 +14,7 @@
|
||||
{ cmd: '/mode', desc: '查看/切换权限模式' },
|
||||
{ cmd: '/cost', desc: '查看会话费用' },
|
||||
{ cmd: '/compact', desc: '压缩上下文' },
|
||||
{ cmd: '/goal', desc: '设置/查看 Codex App 持久目标' },
|
||||
{ cmd: '/init', desc: '生成/更新 Agent 指南文件' },
|
||||
{ cmd: '/help', desc: '显示帮助' },
|
||||
];
|
||||
@@ -158,6 +159,7 @@
|
||||
let fileBrowserState = null;
|
||||
let directoryPickerState = null;
|
||||
let codexAppUserInputModal = null;
|
||||
let codexAppApprovalModal = null;
|
||||
let pendingNewSessionRequest = null;
|
||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||
let pendingInitialSessionLoad = false;
|
||||
@@ -1165,6 +1167,98 @@
|
||||
}
|
||||
}
|
||||
|
||||
function codexAppApprovalPayloadText(payload) {
|
||||
if (payload === null || payload === undefined) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCodexAppApprovalModal(sendCancel = false) {
|
||||
if (!codexAppApprovalModal) return;
|
||||
const { overlay, escapeHandler, requestId, sessionId } = codexAppApprovalModal;
|
||||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
codexAppApprovalModal = null;
|
||||
if (sendCancel && requestId) {
|
||||
send({
|
||||
type: 'codex_app_approval_response',
|
||||
action: 'cancel',
|
||||
sessionId,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function submitCodexAppApproval(action) {
|
||||
if (!codexAppApprovalModal) return;
|
||||
const { requestId, sessionId } = codexAppApprovalModal;
|
||||
send({
|
||||
type: 'codex_app_approval_response',
|
||||
action,
|
||||
sessionId,
|
||||
requestId,
|
||||
});
|
||||
closeCodexAppApprovalModal(false);
|
||||
}
|
||||
|
||||
function showCodexAppApprovalModal(msg) {
|
||||
closeCodexAppApprovalModal(true);
|
||||
const payloadText = codexAppApprovalPayloadText(msg.payload);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay codex-approval-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-panel codex-approval-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">${escapeHtml(msg.title || 'Codex App 请求审批')}</span>
|
||||
<button class="modal-close-btn" type="button" data-codex-approval-cancel>✕</button>
|
||||
</div>
|
||||
<div class="modal-body codex-approval-body">
|
||||
${msg.summary ? `<div class="codex-approval-summary">${escapeHtml(msg.summary)}</div>` : ''}
|
||||
${msg.reason ? `<div class="codex-approval-reason">${escapeHtml(msg.reason)}</div>` : ''}
|
||||
<div class="codex-approval-meta">
|
||||
<span>${escapeHtml(msg.approvalType || 'request')}</span>
|
||||
${msg.itemId ? `<span>${escapeHtml(msg.itemId)}</span>` : ''}
|
||||
</div>
|
||||
${payloadText ? `<pre class="codex-approval-payload">${escapeHtml(payloadText)}</pre>` : ''}
|
||||
</div>
|
||||
<div class="modal-footer codex-approval-footer">
|
||||
<button class="modal-btn-secondary" type="button" data-codex-approval-cancel>取消</button>
|
||||
<button class="modal-btn-secondary codex-approval-deny-btn" type="button" data-codex-approval-action="deny">拒绝</button>
|
||||
<button class="modal-btn-primary" type="button" data-codex-approval-action="approve">本次批准</button>
|
||||
${msg.allowSessionScope ? '<button class="modal-btn-primary" type="button" data-codex-approval-action="approve_session">本会话批准</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const escapeHandler = (e) => {
|
||||
if (e.key === 'Escape') closeCodexAppApprovalModal(true);
|
||||
};
|
||||
document.addEventListener('keydown', escapeHandler);
|
||||
|
||||
codexAppApprovalModal = {
|
||||
overlay,
|
||||
requestId: msg.requestId || '',
|
||||
sessionId: msg.sessionId || '',
|
||||
escapeHandler,
|
||||
};
|
||||
|
||||
overlay.querySelectorAll('[data-codex-approval-cancel]').forEach((button) => {
|
||||
button.addEventListener('click', () => closeCodexAppApprovalModal(true));
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeCodexAppApprovalModal(true);
|
||||
});
|
||||
overlay.querySelectorAll('[data-codex-approval-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => submitCodexAppApproval(button.dataset.codexApprovalAction || 'cancel'));
|
||||
});
|
||||
overlay.querySelector('[data-codex-approval-action="approve"]')?.focus();
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
|
||||
return String(value || '').replace(/["\\]/g, '\\$&');
|
||||
@@ -3135,6 +3229,13 @@
|
||||
showCodexAppUserInputModal(msg);
|
||||
break;
|
||||
|
||||
case 'codex_app_approval_request':
|
||||
if (msg.sessionId && msg.sessionId !== currentSessionId) {
|
||||
showToast('Codex App 需要审批', msg.sessionId);
|
||||
}
|
||||
showCodexAppApprovalModal(msg);
|
||||
break;
|
||||
|
||||
case 'ccweb_mcp_child_agent_update':
|
||||
applyCcwebMcpChildAgentUpdate(msg);
|
||||
break;
|
||||
|
||||
@@ -4153,6 +4153,61 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
.codex-user-input-text:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.codex-approval-panel {
|
||||
max-width: 560px;
|
||||
}
|
||||
.codex-approval-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.codex-approval-summary {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
.codex-approval-reason {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.codex-approval-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.codex-approval-meta span {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.codex-approval-payload {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.codex-approval-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.codex-approval-deny-btn {
|
||||
color: var(--danger);
|
||||
}
|
||||
.modal-quick-picks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -88,6 +88,7 @@ function ensureThread(threadId, params = {}) {
|
||||
activeTurnId: null,
|
||||
timer: null,
|
||||
steers: [],
|
||||
goal: null,
|
||||
});
|
||||
}
|
||||
const thread = threads.get(id);
|
||||
@@ -481,6 +482,20 @@ function completeGuidedInputTurn(thread, turnId) {
|
||||
});
|
||||
}
|
||||
|
||||
function completeApprovalTurn(thread, turnId) {
|
||||
requestClient('item/commandExecution/requestApproval', {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
itemId: 'approval-command-call',
|
||||
reason: 'Need to run an approval-gated command',
|
||||
command: 'echo approved',
|
||||
cwd: thread.cwd,
|
||||
}, (message) => {
|
||||
const decision = message.result?.decision || 'missing';
|
||||
completeTurn(thread, turnId, `approval decision: ${decision}`);
|
||||
});
|
||||
}
|
||||
|
||||
function completeEmptyReasoningTurn(thread, turnId, text) {
|
||||
send({
|
||||
method: 'item/started',
|
||||
@@ -572,6 +587,11 @@ function startTurn(params) {
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (/approval/i.test(text)) {
|
||||
completeApprovalTurn(thread, turnId);
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
|
||||
if (/recover/i.test(text)) {
|
||||
send({
|
||||
@@ -684,6 +704,44 @@ function handleRequest(message) {
|
||||
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
||||
return;
|
||||
}
|
||||
if (method === 'thread/goal/get') {
|
||||
const thread = ensureThread(params.threadId, params);
|
||||
send({ id, result: { goal: thread.goal } });
|
||||
return;
|
||||
}
|
||||
if (method === 'thread/goal/set') {
|
||||
const thread = ensureThread(params.threadId, params);
|
||||
const now = Date.now();
|
||||
const previous = thread.goal || {};
|
||||
const objective = String(params.objective || previous.objective || 'mock goal').trim();
|
||||
thread.goal = {
|
||||
threadId: thread.id,
|
||||
objective,
|
||||
status: String(params.status || previous.status || 'active'),
|
||||
tokenBudget: previous.tokenBudget ?? null,
|
||||
tokensUsed: previous.tokensUsed ?? 3,
|
||||
timeUsedSeconds: previous.timeUsedSeconds ?? 0,
|
||||
createdAt: previous.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
send({
|
||||
method: 'thread/goal/updated',
|
||||
params: { threadId: thread.id, goal: thread.goal },
|
||||
});
|
||||
send({ id, result: { goal: thread.goal } });
|
||||
return;
|
||||
}
|
||||
if (method === 'thread/goal/clear') {
|
||||
const thread = ensureThread(params.threadId, params);
|
||||
const cleared = !!thread.goal;
|
||||
thread.goal = null;
|
||||
send({
|
||||
method: 'thread/goal/cleared',
|
||||
params: { threadId: thread.id },
|
||||
});
|
||||
send({ id, result: { cleared } });
|
||||
return;
|
||||
}
|
||||
if (method === 'turn/start') {
|
||||
send({ id, result: startTurn(params) });
|
||||
return;
|
||||
|
||||
@@ -260,7 +260,16 @@ function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
|
||||
clearInterval(timer);
|
||||
const recentTypes = messages.slice(-12).map((m) => m?.type).join(', ');
|
||||
const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', ');
|
||||
reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}])`));
|
||||
const recentDetails = messages.slice(-6).map((m) => JSON.stringify({
|
||||
type: m?.type,
|
||||
sessionId: m?.sessionId,
|
||||
status: m?.status,
|
||||
clientMessageId: m?.clientMessageId,
|
||||
code: m?.code,
|
||||
text: typeof m?.text === 'string' ? m.text.slice(0, 120) : undefined,
|
||||
message: typeof m?.message === 'string' ? m.message.slice(0, 120) : undefined,
|
||||
})).join(' | ');
|
||||
reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}], recentDetails=[${recentDetails}])`));
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
@@ -432,6 +441,10 @@ function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = '
|
||||
'[projects."/tmp/project-b"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[mcp_servers.reg-config]',
|
||||
'command = "node"',
|
||||
'args = ["regression-mcp.js"]',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
@@ -634,6 +647,23 @@ async function main() {
|
||||
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
|
||||
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg-config', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
|
||||
assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-config'), 'Composer slash suggestions should include MCP servers from Codex config');
|
||||
|
||||
const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
storedComposerFixture.messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Runtime tools include mcp__regRuntime__inspect_schema and mcp:reg-state/query.',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
fs.writeFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), JSON.stringify(storedComposerFixture, null, 2));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-runtime', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const slashMcpRuntimeComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-runtime');
|
||||
assert(slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should include MCP servers from session tool names');
|
||||
assert(slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should include MCP servers from mcp:server labels');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
|
||||
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
|
||||
@@ -972,6 +1002,9 @@ async function main() {
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||||
const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
|
||||
assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-goal-slash', trigger: '/', query: 'go', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||||
const codexAppGoalSlashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-goal-slash');
|
||||
assert(codexAppGoalSlashComposer.items.some((item) => item.kind === 'command' && item.name === '/goal'), 'Codex App composer slash suggestions should include /goal');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
||||
@@ -982,6 +1015,27 @@ async function main() {
|
||||
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||||
assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||||
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
|
||||
assert(/Goal paused/.test(codexAppGoalPause.message || ''), 'Codex App /goal pause should pause the current goal');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal resume', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || ''));
|
||||
assert(/Goal active/.test(codexAppGoalResume.message || ''), 'Codex App /goal resume should resume the current goal');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal clear', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalClear = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal cleared/.test(msg.message || ''));
|
||||
assert(/Goal cleared/.test(codexAppGoalClear.message || ''), 'Codex App /goal clear should clear the current goal');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || ''));
|
||||
assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists');
|
||||
const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
@@ -1108,6 +1162,27 @@ async function main() {
|
||||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||||
const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
|
||||
assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id');
|
||||
assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'codex_app_approval_response',
|
||||
action: 'approve_session',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
requestId: approvalRequest.requestId,
|
||||
}));
|
||||
const approvalSubmitted = await nextMessage(messages, ws, (msg) =>
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/本会话执行/.test(msg.message || '')
|
||||
);
|
||||
assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint');
|
||||
const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || ''));
|
||||
assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
await sleep(150);
|
||||
|
||||
587
server.js
587
server.js
@@ -68,6 +68,7 @@ const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECO
|
||||
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
|
||||
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
|
||||
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
|
||||
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000;
|
||||
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
|
||||
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
|
||||
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
|
||||
@@ -611,6 +612,8 @@ const ccwebMcpChildThreads = new Map();
|
||||
const pendingCrossConversationReplies = new Map();
|
||||
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
|
||||
const pendingCodexAppUserInputs = new Map();
|
||||
// Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer }
|
||||
const pendingCodexAppApprovals = new Map();
|
||||
let codexAppClient = null;
|
||||
let codexAppClientSignature = '';
|
||||
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
|
||||
@@ -714,6 +717,69 @@ function parseTomlStringValue(value) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseTomlBareKeyPath(pathText) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const ch of String(pathText || '').trim()) {
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\' && quote === '"') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
quote = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === '\'') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '.') {
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function loadCodexMcpServerNamesFromToml() {
|
||||
try {
|
||||
const configPath = getLocalCodexConfigTomlPath();
|
||||
if (!configPath || !fs.existsSync(configPath)) return [];
|
||||
const names = [];
|
||||
const seen = new Set();
|
||||
const text = fs.readFileSync(configPath, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (!match) continue;
|
||||
const parts = parseTomlBareKeyPath(match[1]);
|
||||
if (parts[0] !== 'mcp_servers' || !parts[1]) continue;
|
||||
const name = String(parts[1]).trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalCodexTomlConfig() {
|
||||
try {
|
||||
const configPath = getLocalCodexConfigTomlPath();
|
||||
@@ -1390,6 +1456,7 @@ const COMPOSER_COMMANDS = [
|
||||
{ name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' },
|
||||
{ name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' },
|
||||
{ name: '/compact', description: '压缩上下文', insertion: '/compact ' },
|
||||
{ name: '/goal', description: '设置/查看/暂停/恢复/清除 Codex App 持久目标', insertion: '/goal ' },
|
||||
{ name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' },
|
||||
{ name: '/help', description: '显示帮助', insertion: '/help ' },
|
||||
];
|
||||
@@ -1619,11 +1686,100 @@ function filterComposerItems(items, query) {
|
||||
return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
|
||||
}
|
||||
|
||||
function listComposerMcpTools() {
|
||||
return CCWEB_MCP_TOOLS.map((tool) => {
|
||||
function normalizeMcpServerName(value) {
|
||||
return String(value || '').trim().replace(/^mcp:/, '').replace(/^mcp__/, '').replace(/__.*$/, '');
|
||||
}
|
||||
|
||||
function isLikelyMcpServerName(value) {
|
||||
const name = normalizeMcpServerName(value);
|
||||
if (!name || name.length > 80) return false;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) return false;
|
||||
return !new Set([
|
||||
'content', 'text', 'input', 'result', 'status', 'server', 'tool', 'mcp',
|
||||
'true', 'false', 'null', 'undefined', 'currentConversationId',
|
||||
]).has(name);
|
||||
}
|
||||
|
||||
function collectComposerMcpNamesFromText(text, names) {
|
||||
const value = String(text || '');
|
||||
for (const match of value.matchAll(/mcp__([A-Za-z0-9_.-]+)__[A-Za-z0-9_.-]+/g)) {
|
||||
const name = normalizeMcpServerName(match[1]);
|
||||
if (isLikelyMcpServerName(name)) names.add(name);
|
||||
}
|
||||
for (const match of value.matchAll(/\bmcp:([A-Za-z0-9_.-]+)(?:\/[A-Za-z0-9_.-]+)?/g)) {
|
||||
const name = normalizeMcpServerName(match[1]);
|
||||
if (isLikelyMcpServerName(name)) names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
function collectComposerMcpNamesFromValue(value, names, depth = 0) {
|
||||
if (depth > 5 || !value) return;
|
||||
if (typeof value === 'string') {
|
||||
collectComposerMcpNamesFromText(value, names);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value.slice(0, 80)) collectComposerMcpNamesFromValue(item, names, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object') return;
|
||||
for (const [key, item] of Object.entries(value).slice(0, 120)) {
|
||||
collectComposerMcpNamesFromText(key, names);
|
||||
collectComposerMcpNamesFromValue(item, names, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function loadComposerMcpServerNamesFromSession(sessionId) {
|
||||
const names = new Set();
|
||||
const session = sessionId ? loadSession(sessionId) : null;
|
||||
collectComposerMcpNamesFromValue(session?.messages || [], names);
|
||||
const state = sessionId ? loadCodexAppTurnState(sessionId) : null;
|
||||
if (state && !state.__invalid) collectComposerMcpNamesFromValue(state, names);
|
||||
return [...names].filter((name) => isLikelyMcpServerName(name));
|
||||
}
|
||||
|
||||
function mcpServerSuggestion(name, options = {}) {
|
||||
const server = normalizeMcpServerName(name);
|
||||
if (!isLikelyMcpServerName(server)) return null;
|
||||
return {
|
||||
kind: 'mcp',
|
||||
name: server,
|
||||
label: `mcp:${server}`,
|
||||
title: `${server} MCP`,
|
||||
description: options.description || `MCP server: ${server}`,
|
||||
insertion: `mcp:${server}`,
|
||||
appendSpace: true,
|
||||
server,
|
||||
source: options.source || 'mcp',
|
||||
itemType: 'server',
|
||||
};
|
||||
}
|
||||
|
||||
function listComposerMcpItems(sessionId) {
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
const push = (item) => {
|
||||
if (!item?.server && !item?.name) return;
|
||||
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(item);
|
||||
};
|
||||
|
||||
for (const name of loadComposerMcpServerNamesFromSession(sessionId)) {
|
||||
push(mcpServerSuggestion(name, { source: 'session' }));
|
||||
}
|
||||
for (const name of loadCodexMcpServerNamesFromToml()) {
|
||||
push(mcpServerSuggestion(name, { source: 'codex-config' }));
|
||||
}
|
||||
push(mcpServerSuggestion('ccweb', {
|
||||
source: 'builtin',
|
||||
description: 'ccweb 内置 MCP server,可用于跨会话协作。',
|
||||
}));
|
||||
for (const tool of CCWEB_MCP_TOOLS) {
|
||||
const name = String(tool?.name || '').trim();
|
||||
const label = `mcp:ccweb/${name}`;
|
||||
return {
|
||||
push({
|
||||
kind: 'mcp',
|
||||
name,
|
||||
label,
|
||||
@@ -1633,8 +1789,10 @@ function listComposerMcpTools() {
|
||||
appendSpace: true,
|
||||
server: 'ccweb',
|
||||
source: 'mcp:ccweb',
|
||||
};
|
||||
}).filter((item) => item.name);
|
||||
itemType: 'tool',
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function mergeComposerSuggestionGroups(...groups) {
|
||||
@@ -1705,7 +1863,7 @@ function listComposerFileSuggestions(sessionId, query) {
|
||||
}
|
||||
|
||||
function listComposerSuggestions(trigger, query, sessionId, agent) {
|
||||
const mcpItems = filterComposerItems(listComposerMcpTools(), query);
|
||||
const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query);
|
||||
if (trigger === '/') {
|
||||
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
||||
kind: 'command',
|
||||
@@ -3612,6 +3770,10 @@ function isContextLimitError(agent, raw) {
|
||||
function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
const entry = activeProcesses.get(sessionId);
|
||||
if (!entry) return;
|
||||
if (entry.pidMonitorCompleteTimer) {
|
||||
clearTimeout(entry.pidMonitorCompleteTimer);
|
||||
entry.pidMonitorCompleteTimer = null;
|
||||
}
|
||||
|
||||
const completeTime = new Date().toISOString();
|
||||
const wsConnected = !!entry.ws;
|
||||
@@ -3622,6 +3784,12 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
|
||||
let contextLimitExceeded = false;
|
||||
|
||||
// 先读完剩余 JSONL,再判定错误类型,避免退出监控早于 tailer 造成漏判。
|
||||
if (entry.tailer) {
|
||||
entry.tailer.readNew();
|
||||
entry.tailer.stop();
|
||||
}
|
||||
|
||||
// Read stderr for error clues
|
||||
let stderrSnippet = '';
|
||||
try {
|
||||
@@ -3659,12 +3827,6 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
requestTooLarge: contextLimitExceeded,
|
||||
});
|
||||
|
||||
// Final read
|
||||
if (entry.tailer) {
|
||||
entry.tailer.readNew();
|
||||
entry.tailer.stop();
|
||||
}
|
||||
|
||||
const pendingSlash = pendingSlashCommands.get(sessionId) || null;
|
||||
if (pendingSlash) pendingSlashCommands.delete(sessionId);
|
||||
|
||||
@@ -3790,12 +3952,18 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
setInterval(() => {
|
||||
for (const [sessionId, entry] of activeProcesses) {
|
||||
if (entry.pid && !isProcessRunning(entry.pid)) {
|
||||
if (entry.pidMonitorCompleteTimer) continue;
|
||||
plog('INFO', 'pid_monitor_detected_exit', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
pid: entry.pid,
|
||||
wsConnected: !!entry.ws,
|
||||
});
|
||||
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
|
||||
entry.pidMonitorCompleteTimer = setTimeout(() => {
|
||||
const latest = activeProcesses.get(sessionId);
|
||||
if (!latest || latest.pid !== entry.pid) return;
|
||||
latest.pidMonitorCompleteTimer = null;
|
||||
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
@@ -4165,6 +4333,9 @@ wss.on('connection', (ws, req) => {
|
||||
case 'codex_app_user_input_response':
|
||||
handleCodexAppUserInputResponse(ws, msg);
|
||||
break;
|
||||
case 'codex_app_approval_response':
|
||||
handleCodexAppApprovalResponse(ws, msg);
|
||||
break;
|
||||
case 'ccweb_mcp_child_agent_close':
|
||||
handleCcwebMcpChildAgentClose(ws, msg);
|
||||
break;
|
||||
@@ -4494,6 +4665,171 @@ function handleFetchModels(ws, msg) {
|
||||
req.end();
|
||||
}
|
||||
|
||||
function parseCodexGoalCommand(text) {
|
||||
const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(String(text || ''));
|
||||
if (!match) return null;
|
||||
const rest = String(match[1] || '').trim();
|
||||
if (!rest) return { action: 'show' };
|
||||
const lower = rest.toLowerCase();
|
||||
if (lower === 'clear') return { action: 'clear' };
|
||||
if (lower === 'pause') return { action: 'pause' };
|
||||
if (lower === 'resume') return { action: 'resume' };
|
||||
if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
|
||||
return {
|
||||
action: 'set',
|
||||
error: `Goal 目标最多 ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} 个字符。`,
|
||||
};
|
||||
}
|
||||
return { action: 'set', objective: rest };
|
||||
}
|
||||
|
||||
function goalNumber(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function goalString(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
|
||||
if (!goal || typeof goal !== 'object') return null;
|
||||
const threadId = goalString(goal.threadId || goal.thread_id || fallbackThreadId);
|
||||
const objective = goalString(goal.objective);
|
||||
const rawStatus = goalString(goal.status) || 'active';
|
||||
return {
|
||||
...goal,
|
||||
threadId,
|
||||
objective,
|
||||
status: rawStatus,
|
||||
tokenBudget: goalNumber(goal.tokenBudget ?? goal.token_budget),
|
||||
tokensUsed: goalNumber(goal.tokensUsed ?? goal.tokens_used) || 0,
|
||||
timeUsedSeconds: goalNumber(goal.timeUsedSeconds ?? goal.time_used_seconds) || 0,
|
||||
createdAt: goalNumber(goal.createdAt ?? goal.created_at) || 0,
|
||||
updatedAt: goalNumber(goal.updatedAt ?? goal.updated_at) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCodexGoalStatus(status) {
|
||||
const normalized = String(status || 'active').trim();
|
||||
const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (compact === 'budgetlimited') return 'budget limited';
|
||||
if (compact === 'complete' || compact === 'completed') return 'complete';
|
||||
if (compact === 'paused') return 'paused';
|
||||
if (compact === 'active') return 'active';
|
||||
if (compact === 'blocked') return 'blocked';
|
||||
return normalized || 'updated';
|
||||
}
|
||||
|
||||
function formatCodexGoalUsage(goal) {
|
||||
const normalized = normalizeCodexThreadGoal(goal);
|
||||
if (!normalized) return 'Goal updated';
|
||||
const parts = [`Goal ${formatCodexGoalStatus(normalized.status)}`];
|
||||
if (normalized.tokenBudget !== null) {
|
||||
parts.push(`${normalized.tokensUsed}/${normalized.tokenBudget} tokens`);
|
||||
} else if (normalized.tokensUsed > 0) {
|
||||
parts.push(`${normalized.tokensUsed} tokens`);
|
||||
}
|
||||
const objective = normalized.objective ? `\n目标: ${normalized.objective}` : '';
|
||||
return `${parts.join(' · ')}${objective}`;
|
||||
}
|
||||
|
||||
async function ensureCodexAppGoalThread(session) {
|
||||
const clientResult = getCodexAppClient();
|
||||
if (clientResult.error) throw new Error(clientResult.error);
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
|
||||
let threadId = getRuntimeSessionId(session);
|
||||
const threadParams = codexAppThreadParams(session);
|
||||
if (threadId) {
|
||||
const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
|
||||
threadId = resumed?.thread?.id || threadId;
|
||||
} else {
|
||||
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
|
||||
threadId = started?.thread?.id || null;
|
||||
}
|
||||
if (!threadId) throw new Error('Codex app-server 未返回 threadId。');
|
||||
|
||||
setRuntimeSessionId(session, threadId);
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
return { client, threadId };
|
||||
}
|
||||
|
||||
function isCodexGoalUnsupportedError(err) {
|
||||
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
|
||||
return err?.code === -32601
|
||||
|| /goals feature is disabled|unsupported remote app-server request|method not found|unknown mock method/i.test(detail);
|
||||
}
|
||||
|
||||
async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
const command = parseCodexGoalCommand(text);
|
||||
if (!command) return;
|
||||
|
||||
if (!session) {
|
||||
wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' });
|
||||
return;
|
||||
}
|
||||
if (!isCodexAppSession(session)) {
|
||||
wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' });
|
||||
return;
|
||||
}
|
||||
if (command.error) {
|
||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { client, threadId } = await ensureCodexAppGoalThread(session);
|
||||
if (command.action === 'show') {
|
||||
const response = await client.request('thread/goal/get', { threadId }, 30000);
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>',
|
||||
});
|
||||
sendSessionList(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.action === 'clear') {
|
||||
const response = await client.request('thread/goal/clear', { threadId }, 30000);
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: response?.cleared ? 'Goal cleared' : 'No goal to clear',
|
||||
});
|
||||
sendSessionList(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.request('thread/goal/set', {
|
||||
threadId,
|
||||
...(command.action === 'set' ? { objective: command.objective } : {}),
|
||||
status: command.action === 'pause' ? 'paused' : 'active',
|
||||
}, 30000);
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: goal ? formatCodexGoalUsage(goal) : 'Goal updated',
|
||||
});
|
||||
sendSessionList(ws);
|
||||
} catch (err) {
|
||||
const message = isCodexGoalUnsupportedError(err)
|
||||
? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。'
|
||||
: `Goal failed: ${err?.message || err}`;
|
||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message });
|
||||
}
|
||||
}
|
||||
|
||||
// === Slash Command Handler ===
|
||||
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
const parts = text.split(/\s+/);
|
||||
@@ -4589,6 +4925,17 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
break;
|
||||
}
|
||||
|
||||
case '/goal': {
|
||||
handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => {
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: session?.id,
|
||||
message: `Goal failed: ${err?.message || err}`,
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case '/compact': {
|
||||
if (!sessionId || !session) {
|
||||
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
||||
@@ -4670,7 +5017,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
message: codexLikeAgent
|
||||
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
||||
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
||||
: base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
|
||||
});
|
||||
break;
|
||||
@@ -6145,6 +6492,200 @@ function resolvePendingCodexAppUserInputsForSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
function codexAppRecord(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function codexAppString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function codexAppApprovalToolName(params = {}) {
|
||||
const record = codexAppRecord(params);
|
||||
return codexAppString(record.toolName)
|
||||
|| codexAppString(record.tool_name)
|
||||
|| codexAppString(record.tool)
|
||||
|| codexAppString(record.name)
|
||||
|| codexAppString(record.permission)
|
||||
|| 'CodexTool';
|
||||
}
|
||||
|
||||
function codexAppApprovalPreview(method, params = {}) {
|
||||
const record = codexAppRecord(params);
|
||||
const itemId = codexAppString(record.itemId) || codexAppString(record.item_id);
|
||||
const reason = codexAppString(record.reason) || codexAppString(record.message);
|
||||
const cwd = codexAppString(record.cwd);
|
||||
|
||||
if (method === 'item/commandExecution/requestApproval') {
|
||||
const command = record.command ?? record.cmd ?? '';
|
||||
return {
|
||||
itemId,
|
||||
approvalType: 'command',
|
||||
title: 'Codex App 请求执行命令',
|
||||
reason,
|
||||
summary: codexAppString(command) || reason || cwd,
|
||||
payload: truncateObj({ command, cwd, reason }, 4000),
|
||||
allowSessionScope: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'item/fileChange/requestApproval') {
|
||||
const grantRoot = codexAppString(record.grantRoot) || codexAppString(record.path);
|
||||
return {
|
||||
itemId,
|
||||
approvalType: 'file_change',
|
||||
title: 'Codex App 请求修改文件',
|
||||
reason,
|
||||
summary: grantRoot || reason || cwd,
|
||||
payload: truncateObj({ grantRoot, cwd, reason, changes: record.changes || null }, 4000),
|
||||
allowSessionScope: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'item/permissions/requestApproval') {
|
||||
return {
|
||||
itemId,
|
||||
approvalType: 'permissions',
|
||||
title: 'Codex App 请求提升权限',
|
||||
reason,
|
||||
summary: reason || cwd || '权限配置请求',
|
||||
payload: truncateObj({ cwd, permissions: record.permissions || {} }, 4000),
|
||||
allowSessionScope: true,
|
||||
};
|
||||
}
|
||||
|
||||
const toolName = codexAppApprovalToolName(record);
|
||||
const input = record.input ?? record.arguments ?? record.params ?? record;
|
||||
return {
|
||||
itemId,
|
||||
approvalType: 'tool',
|
||||
title: `Codex App 请求调用 ${toolName}`,
|
||||
reason,
|
||||
summary: reason || toolName,
|
||||
payload: truncateObj(input, 4000),
|
||||
allowSessionScope: true,
|
||||
toolName,
|
||||
};
|
||||
}
|
||||
|
||||
function codexAppApprovalDecisionFromAction(action) {
|
||||
switch (String(action || '').trim()) {
|
||||
case 'approve':
|
||||
return 'approved';
|
||||
case 'approve_session':
|
||||
return 'approved_for_session';
|
||||
case 'deny':
|
||||
return 'denied';
|
||||
default:
|
||||
return 'abort';
|
||||
}
|
||||
}
|
||||
|
||||
function codexAppDecisionResponse(decision) {
|
||||
switch (decision) {
|
||||
case 'approved':
|
||||
return { decision: 'accept' };
|
||||
case 'approved_for_session':
|
||||
return { decision: 'acceptForSession' };
|
||||
case 'denied':
|
||||
return { decision: 'decline' };
|
||||
default:
|
||||
return { decision: 'cancel' };
|
||||
}
|
||||
}
|
||||
|
||||
function codexAppPermissionsResponse(params = {}, decision = 'abort') {
|
||||
if (decision === 'approved' || decision === 'approved_for_session') {
|
||||
return {
|
||||
permissions: codexAppRecord(params).permissions || {},
|
||||
scope: decision === 'approved_for_session' ? 'session' : 'turn',
|
||||
};
|
||||
}
|
||||
return {
|
||||
permissions: {
|
||||
network: null,
|
||||
fileSystem: null,
|
||||
},
|
||||
scope: 'turn',
|
||||
};
|
||||
}
|
||||
|
||||
function codexAppApprovalResponse(method, params = {}, action = 'cancel') {
|
||||
const decision = codexAppApprovalDecisionFromAction(action);
|
||||
if (method === 'item/permissions/requestApproval') {
|
||||
return codexAppPermissionsResponse(params, decision);
|
||||
}
|
||||
return codexAppDecisionResponse(decision);
|
||||
}
|
||||
|
||||
function requestCodexAppApproval(routed, method, params = {}) {
|
||||
const targetWs = routed?.entry?.ws || findViewingSessionWs(routed?.sessionId);
|
||||
if (!routed?.sessionId || !targetWs) {
|
||||
return Promise.resolve(codexAppApprovalResponse(method, params, 'cancel'));
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const preview = codexAppApprovalPreview(method, params);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingCodexAppApprovals.delete(requestId);
|
||||
resolve(codexAppApprovalResponse(method, params, 'cancel'));
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
pendingCodexAppApprovals.set(requestId, {
|
||||
sessionId: routed.sessionId,
|
||||
method,
|
||||
params,
|
||||
resolve,
|
||||
timer,
|
||||
});
|
||||
|
||||
wsSend(targetWs, {
|
||||
type: 'codex_app_approval_request',
|
||||
sessionId: routed.sessionId,
|
||||
requestId,
|
||||
method,
|
||||
...preview,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCodexAppApprovalResponse(ws, msg = {}) {
|
||||
const requestId = String(msg.requestId || '').trim();
|
||||
const pending = pendingCodexAppApprovals.get(requestId);
|
||||
if (!pending) {
|
||||
wsSend(ws, { type: 'error', code: 'codexapp_approval_not_found', message: 'Codex App 审批请求不存在或已超时。' });
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCodexAppApprovals.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
const action = String(msg.action || 'cancel').trim();
|
||||
const result = codexAppApprovalResponse(pending.method, pending.params, action);
|
||||
const approved = action === 'approve' || action === 'approve_session';
|
||||
const message = approved
|
||||
? (action === 'approve_session' ? '已批准 Codex App 本会话执行。' : '已批准 Codex App 本次执行。')
|
||||
: (action === 'deny' ? '已拒绝 Codex App 执行请求。' : '已取消 Codex App 审批请求。');
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: pending.sessionId,
|
||||
tone: approved ? 'info' : 'warning',
|
||||
transient: true,
|
||||
autoDismissMs: 5000,
|
||||
message,
|
||||
});
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
function resolvePendingCodexAppApprovalsForSession(sessionId) {
|
||||
for (const [requestId, pending] of pendingCodexAppApprovals) {
|
||||
if (pending.sessionId !== sessionId) continue;
|
||||
pendingCodexAppApprovals.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(codexAppApprovalResponse(pending.method, pending.params, 'cancel'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCodexAppServerRequest(request) {
|
||||
const method = request?.method || '';
|
||||
const params = request?.params || {};
|
||||
@@ -6152,21 +6693,24 @@ function handleCodexAppServerRequest(request) {
|
||||
const dynamicToolResponse = method === 'item/tool/call'
|
||||
? handleCodexAppDynamicToolCall(routed, params)
|
||||
: null;
|
||||
if (!dynamicToolResponse && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
|
||||
const isApprovalRequest = method === 'item/commandExecution/requestApproval'
|
||||
|| method === 'item/fileChange/requestApproval'
|
||||
|| method === 'item/permissions/requestApproval'
|
||||
|| method === 'item/tool/requestApproval';
|
||||
if (!dynamicToolResponse && !isApprovalRequest && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
|
||||
wsSend(routed.entry.ws, {
|
||||
type: 'system_message',
|
||||
sessionId: routed.sessionId,
|
||||
message: `Codex App 请求客户端处理 ${method},当前 cc-web 暂未接入交互式审批,已按保守策略拒绝。`,
|
||||
message: `Codex App 请求客户端处理 ${method},cc-web 暂不支持该请求类型,已按保守策略拒绝。`,
|
||||
});
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'item/commandExecution/requestApproval':
|
||||
return { decision: 'cancel' };
|
||||
case 'item/fileChange/requestApproval':
|
||||
return { decision: 'cancel' };
|
||||
case 'item/permissions/requestApproval':
|
||||
return { permissions: {}, scope: 'turn' };
|
||||
case 'item/tool/requestApproval':
|
||||
return requestCodexAppApproval(routed, method, params);
|
||||
case 'item/tool/call':
|
||||
if (dynamicToolResponse) return dynamicToolResponse;
|
||||
return {
|
||||
@@ -6217,7 +6761,7 @@ function codexAppPermissionParams(session) {
|
||||
return { approvalPolicy: 'never', sandbox: 'read-only' };
|
||||
}
|
||||
if (mode === 'default') {
|
||||
return { approvalPolicy: 'never', sandbox: 'workspace-write' };
|
||||
return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
|
||||
}
|
||||
return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
|
||||
}
|
||||
@@ -6229,7 +6773,7 @@ function codexAppTurnPermissionParams(session) {
|
||||
}
|
||||
if (mode === 'default') {
|
||||
return {
|
||||
approvalPolicy: 'never',
|
||||
approvalPolicy: 'on-request',
|
||||
sandboxPolicy: {
|
||||
type: 'workspaceWrite',
|
||||
writableRoots: [],
|
||||
@@ -6500,6 +7044,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
const entry = activeCodexAppTurns.get(sessionId);
|
||||
if (!entry) return;
|
||||
resolvePendingCodexAppUserInputsForSession(sessionId);
|
||||
resolvePendingCodexAppApprovalsForSession(sessionId);
|
||||
|
||||
const explicitError = options.error || null;
|
||||
const rawError = explicitError || entry.lastError || null;
|
||||
|
||||
Reference in New Issue
Block a user