feat: support codex app goal command

This commit is contained in:
shiyue
2026-06-17 14:08:32 +08:00
parent 7e01f24e61
commit b4bcd170d2
8 changed files with 3129 additions and 23 deletions

View File

@@ -2,7 +2,7 @@
(function () { (function () {
'use strict'; '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 WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100; const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120; const COMPOSER_SUGGESTION_DEBOUNCE = 120;
@@ -14,6 +14,7 @@
{ cmd: '/mode', desc: '查看/切换权限模式' }, { cmd: '/mode', desc: '查看/切换权限模式' },
{ cmd: '/cost', desc: '查看会话费用' }, { cmd: '/cost', desc: '查看会话费用' },
{ cmd: '/compact', desc: '压缩上下文' }, { cmd: '/compact', desc: '压缩上下文' },
{ cmd: '/goal', desc: '设置/查看 Codex App 持久目标' },
{ cmd: '/init', desc: '生成/更新 Agent 指南文件' }, { cmd: '/init', desc: '生成/更新 Agent 指南文件' },
{ cmd: '/help', desc: '显示帮助' }, { cmd: '/help', desc: '显示帮助' },
]; ];
@@ -158,6 +159,7 @@
let fileBrowserState = null; let fileBrowserState = null;
let directoryPickerState = null; let directoryPickerState = null;
let codexAppUserInputModal = null; let codexAppUserInputModal = null;
let codexAppApprovalModal = null;
let pendingNewSessionRequest = null; let pendingNewSessionRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false; 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) { function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || '')); if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/["\\]/g, '\\$&'); return String(value || '').replace(/["\\]/g, '\\$&');
@@ -3135,6 +3229,13 @@
showCodexAppUserInputModal(msg); showCodexAppUserInputModal(msg);
break; 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': case 'ccweb_mcp_child_agent_update':
applyCcwebMcpChildAgentUpdate(msg); applyCcwebMcpChildAgentUpdate(msg);
break; break;

View File

@@ -4153,6 +4153,61 @@ html[data-theme='coolvibe'] .settings-back:hover {
.codex-user-input-text:focus { .codex-user-input-text:focus {
border-color: var(--accent); 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 { .modal-quick-picks {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -88,6 +88,7 @@ function ensureThread(threadId, params = {}) {
activeTurnId: null, activeTurnId: null,
timer: null, timer: null,
steers: [], steers: [],
goal: null,
}); });
} }
const thread = threads.get(id); 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) { function completeEmptyReasoningTurn(thread, turnId, text) {
send({ send({
method: 'item/started', method: 'item/started',
@@ -572,6 +587,11 @@ function startTurn(params) {
return { turn: { id: turnId, status: 'running', items: [] } }; 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; const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
if (/recover/i.test(text)) { if (/recover/i.test(text)) {
send({ 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' } }); 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; 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') { if (method === 'turn/start') {
send({ id, result: startTurn(params) }); send({ id, result: startTurn(params) });
return; return;

View File

@@ -260,7 +260,16 @@ function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
clearInterval(timer); clearInterval(timer);
const recentTypes = messages.slice(-12).map((m) => m?.type).join(', '); const recentTypes = messages.slice(-12).map((m) => m?.type).join(', ');
const pendingTypes = messages.slice(0, 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); }, 50);
}); });
@@ -432,6 +441,10 @@ function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = '
'[projects."/tmp/project-b"]', '[projects."/tmp/project-b"]',
'trust_level = "trusted"', 'trust_level = "trusted"',
'', '',
'[mcp_servers.reg-config]',
'command = "node"',
'args = ["regression-mcp.js"]',
'',
].join('\n')); ].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'); 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'); 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' })); 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'); 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'); 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' })); 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'); 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'); 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' })); 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 || '')); 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'); 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); 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' })); 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) => ( const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' && 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'); 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); 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' })); 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 nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
await sleep(150); await sleep(150);

585
server.js
View File

@@ -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 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 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 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_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_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [ const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
@@ -611,6 +612,8 @@ const ccwebMcpChildThreads = new Map();
const pendingCrossConversationReplies = new Map(); const pendingCrossConversationReplies = new Map();
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer } // Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
const pendingCodexAppUserInputs = new Map(); const pendingCodexAppUserInputs = new Map();
// Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer }
const pendingCodexAppApprovals = new Map();
let codexAppClient = null; let codexAppClient = null;
let codexAppClientSignature = ''; let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json'; const CODEX_APP_STATE_FILE = 'codexapp-state.json';
@@ -714,6 +717,69 @@ function parseTomlStringValue(value) {
return raw; 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() { function loadLocalCodexTomlConfig() {
try { try {
const configPath = getLocalCodexConfigTomlPath(); const configPath = getLocalCodexConfigTomlPath();
@@ -1390,6 +1456,7 @@ const COMPOSER_COMMANDS = [
{ name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' }, { name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' },
{ name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' }, { name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' },
{ name: '/compact', description: '压缩上下文', insertion: '/compact ' }, { name: '/compact', description: '压缩上下文', insertion: '/compact ' },
{ name: '/goal', description: '设置/查看/暂停/恢复/清除 Codex App 持久目标', insertion: '/goal ' },
{ name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' }, { name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' },
{ name: '/help', description: '显示帮助', insertion: '/help ' }, { name: '/help', description: '显示帮助', insertion: '/help ' },
]; ];
@@ -1619,11 +1686,100 @@ function filterComposerItems(items, query) {
return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT); return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
} }
function listComposerMcpTools() { function normalizeMcpServerName(value) {
return CCWEB_MCP_TOOLS.map((tool) => { 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 name = String(tool?.name || '').trim();
const label = `mcp:ccweb/${name}`; const label = `mcp:ccweb/${name}`;
return { push({
kind: 'mcp', kind: 'mcp',
name, name,
label, label,
@@ -1633,8 +1789,10 @@ function listComposerMcpTools() {
appendSpace: true, appendSpace: true,
server: 'ccweb', server: 'ccweb',
source: 'mcp:ccweb', source: 'mcp:ccweb',
}; itemType: 'tool',
}).filter((item) => item.name); });
}
return items;
} }
function mergeComposerSuggestionGroups(...groups) { function mergeComposerSuggestionGroups(...groups) {
@@ -1705,7 +1863,7 @@ function listComposerFileSuggestions(sessionId, query) {
} }
function listComposerSuggestions(trigger, query, sessionId, agent) { function listComposerSuggestions(trigger, query, sessionId, agent) {
const mcpItems = filterComposerItems(listComposerMcpTools(), query); const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query);
if (trigger === '/') { if (trigger === '/') {
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({ const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
kind: 'command', kind: 'command',
@@ -3612,6 +3770,10 @@ function isContextLimitError(agent, raw) {
function handleProcessComplete(sessionId, exitCode, signal) { function handleProcessComplete(sessionId, exitCode, signal) {
const entry = activeProcesses.get(sessionId); const entry = activeProcesses.get(sessionId);
if (!entry) return; if (!entry) return;
if (entry.pidMonitorCompleteTimer) {
clearTimeout(entry.pidMonitorCompleteTimer);
entry.pidMonitorCompleteTimer = null;
}
const completeTime = new Date().toISOString(); const completeTime = new Date().toISOString();
const wsConnected = !!entry.ws; const wsConnected = !!entry.ws;
@@ -3622,6 +3784,12 @@ function handleProcessComplete(sessionId, exitCode, signal) {
const pendingRetry = pendingCompactRetries.get(sessionId) || null; const pendingRetry = pendingCompactRetries.get(sessionId) || null;
let contextLimitExceeded = false; let contextLimitExceeded = false;
// 先读完剩余 JSONL再判定错误类型避免退出监控早于 tailer 造成漏判。
if (entry.tailer) {
entry.tailer.readNew();
entry.tailer.stop();
}
// Read stderr for error clues // Read stderr for error clues
let stderrSnippet = ''; let stderrSnippet = '';
try { try {
@@ -3659,12 +3827,6 @@ function handleProcessComplete(sessionId, exitCode, signal) {
requestTooLarge: contextLimitExceeded, requestTooLarge: contextLimitExceeded,
}); });
// Final read
if (entry.tailer) {
entry.tailer.readNew();
entry.tailer.stop();
}
const pendingSlash = pendingSlashCommands.get(sessionId) || null; const pendingSlash = pendingSlashCommands.get(sessionId) || null;
if (pendingSlash) pendingSlashCommands.delete(sessionId); if (pendingSlash) pendingSlashCommands.delete(sessionId);
@@ -3790,12 +3952,18 @@ function handleProcessComplete(sessionId, exitCode, signal) {
setInterval(() => { setInterval(() => {
for (const [sessionId, entry] of activeProcesses) { for (const [sessionId, entry] of activeProcesses) {
if (entry.pid && !isProcessRunning(entry.pid)) { if (entry.pid && !isProcessRunning(entry.pid)) {
if (entry.pidMonitorCompleteTimer) continue;
plog('INFO', 'pid_monitor_detected_exit', { plog('INFO', 'pid_monitor_detected_exit', {
sessionId: sessionId.slice(0, 8), sessionId: sessionId.slice(0, 8),
pid: entry.pid, pid: entry.pid,
wsConnected: !!entry.ws, wsConnected: !!entry.ws,
}); });
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)'); handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
}, 1000);
} }
} }
}, 2000); }, 2000);
@@ -4165,6 +4333,9 @@ wss.on('connection', (ws, req) => {
case 'codex_app_user_input_response': case 'codex_app_user_input_response':
handleCodexAppUserInputResponse(ws, msg); handleCodexAppUserInputResponse(ws, msg);
break; break;
case 'codex_app_approval_response':
handleCodexAppApprovalResponse(ws, msg);
break;
case 'ccweb_mcp_child_agent_close': case 'ccweb_mcp_child_agent_close':
handleCcwebMcpChildAgentClose(ws, msg); handleCcwebMcpChildAgentClose(ws, msg);
break; break;
@@ -4494,6 +4665,171 @@ function handleFetchModels(ws, msg) {
req.end(); 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 === // === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId, fallbackAgent) { function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
const parts = text.split(/\s+/); const parts = text.split(/\s+/);
@@ -4589,6 +4925,17 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
break; 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': { case '/compact': {
if (!sessionId || !session) { if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' }); wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
@@ -4670,7 +5017,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
wsSend(ws, { wsSend(ws, {
type: 'system_message', type: 'system_message',
message: codexLikeAgent 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', : base + '\n/model [名称] — 查看/切换模型opus, sonnet, haiku\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
}); });
break; 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) { function handleCodexAppServerRequest(request) {
const method = request?.method || ''; const method = request?.method || '';
const params = request?.params || {}; const params = request?.params || {};
@@ -6152,21 +6693,24 @@ function handleCodexAppServerRequest(request) {
const dynamicToolResponse = method === 'item/tool/call' const dynamicToolResponse = method === 'item/tool/call'
? handleCodexAppDynamicToolCall(routed, params) ? handleCodexAppDynamicToolCall(routed, params)
: null; : 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, { wsSend(routed.entry.ws, {
type: 'system_message', type: 'system_message',
sessionId: routed.sessionId, sessionId: routed.sessionId,
message: `Codex App 请求客户端处理 ${method}当前 cc-web 暂未接入交互式审批,已按保守策略拒绝。`, message: `Codex App 请求客户端处理 ${method}cc-web 暂不支持该请求类型,已按保守策略拒绝。`,
}); });
} }
switch (method) { switch (method) {
case 'item/commandExecution/requestApproval': case 'item/commandExecution/requestApproval':
return { decision: 'cancel' };
case 'item/fileChange/requestApproval': case 'item/fileChange/requestApproval':
return { decision: 'cancel' };
case 'item/permissions/requestApproval': case 'item/permissions/requestApproval':
return { permissions: {}, scope: 'turn' }; case 'item/tool/requestApproval':
return requestCodexAppApproval(routed, method, params);
case 'item/tool/call': case 'item/tool/call':
if (dynamicToolResponse) return dynamicToolResponse; if (dynamicToolResponse) return dynamicToolResponse;
return { return {
@@ -6217,7 +6761,7 @@ function codexAppPermissionParams(session) {
return { approvalPolicy: 'never', sandbox: 'read-only' }; return { approvalPolicy: 'never', sandbox: 'read-only' };
} }
if (mode === 'default') { if (mode === 'default') {
return { approvalPolicy: 'never', sandbox: 'workspace-write' }; return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
} }
return { approvalPolicy: 'never', sandbox: 'danger-full-access' }; return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
} }
@@ -6229,7 +6773,7 @@ function codexAppTurnPermissionParams(session) {
} }
if (mode === 'default') { if (mode === 'default') {
return { return {
approvalPolicy: 'never', approvalPolicy: 'on-request',
sandboxPolicy: { sandboxPolicy: {
type: 'workspaceWrite', type: 'workspaceWrite',
writableRoots: [], writableRoots: [],
@@ -6500,6 +7044,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
const entry = activeCodexAppTurns.get(sessionId); const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return; if (!entry) return;
resolvePendingCodexAppUserInputsForSession(sessionId); resolvePendingCodexAppUserInputsForSession(sessionId);
resolvePendingCodexAppApprovalsForSession(sessionId);
const explicitError = options.error || null; const explicitError = options.error || null;
const rawError = explicitError || entry.lastError || null; const rawError = explicitError || entry.lastError || null;

2095
子代里.txt Normal file

File diff suppressed because one or more lines are too long

15
返回1.txt Normal file

File diff suppressed because one or more lines are too long

162
返回2.txt Normal file

File diff suppressed because one or more lines are too long