feat: enhance codex app and cross-conversation messaging
This commit is contained in:
@@ -266,9 +266,9 @@ function createAgentRuntime(deps) {
|
||||
|
||||
const currentText = entry.fullText || '';
|
||||
const hasExistingText = /\S/.test(currentText);
|
||||
const hasParagraphBoundary = /\n\s*\n\s*$/.test(currentText) || /^\s*\n\s*\n/.test(nextText);
|
||||
const separator = hasExistingText && !hasParagraphBoundary
|
||||
? (/\n\s*$/.test(currentText) ? '\n' : '\n\n')
|
||||
const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(nextText);
|
||||
const separator = hasExistingText && !hasVisualBoundary
|
||||
? (/\n\s*$/.test(currentText) ? '\n---\n\n' : '\n\n---\n\n')
|
||||
: '';
|
||||
const chunk = separator + nextText;
|
||||
entry.fullText += chunk;
|
||||
|
||||
@@ -18,7 +18,7 @@ const TOOLS = [
|
||||
properties: {
|
||||
agent: {
|
||||
type: 'string',
|
||||
enum: ['claude', 'codex'],
|
||||
enum: ['claude', 'codex', 'codexapp'],
|
||||
description: '可选。只返回指定 Agent 的对话。',
|
||||
},
|
||||
status: {
|
||||
@@ -55,6 +55,25 @@ const TOOLS = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ccweb_request_reply',
|
||||
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后自动把回复发回当前对话。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetConversationId: {
|
||||
type: 'string',
|
||||
description: '目标对话 ID。',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '要发送到目标对话的纯文本消息。',
|
||||
},
|
||||
},
|
||||
required: ['targetConversationId', 'content'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function writeMessage(message) {
|
||||
|
||||
472
lib/codex-app-runtime.js
Normal file
472
lib/codex-app-runtime.js
Normal file
@@ -0,0 +1,472 @@
|
||||
'use strict';
|
||||
|
||||
function createCodexAppRuntime(deps = {}) {
|
||||
const {
|
||||
wsSend,
|
||||
loadSession,
|
||||
saveSession,
|
||||
truncateObj,
|
||||
} = deps;
|
||||
|
||||
function truncate(value, maxLen) {
|
||||
if (typeof truncateObj === 'function') return truncateObj(value, maxLen);
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
|
||||
}
|
||||
|
||||
function sendRuntime(entry, sessionId, payload) {
|
||||
wsSend(entry.ws, { ...payload, sessionId });
|
||||
}
|
||||
|
||||
function itemKind(item) {
|
||||
switch (item?.type) {
|
||||
case 'commandExecution':
|
||||
return 'command_execution';
|
||||
case 'mcpToolCall':
|
||||
return 'mcp_tool_call';
|
||||
case 'fileChange':
|
||||
return 'file_change';
|
||||
case 'reasoning':
|
||||
return 'reasoning';
|
||||
case 'dynamicToolCall':
|
||||
return 'dynamic_tool_call';
|
||||
case 'collabAgentToolCall':
|
||||
return 'collab_agent_tool_call';
|
||||
case 'webSearch':
|
||||
return 'web_search';
|
||||
case 'imageView':
|
||||
return 'image_view';
|
||||
case 'imageGeneration':
|
||||
return 'image_generation';
|
||||
default:
|
||||
return item?.type || 'codex_app_item';
|
||||
}
|
||||
}
|
||||
|
||||
function itemName(item) {
|
||||
switch (item?.type) {
|
||||
case 'commandExecution':
|
||||
return 'CommandExecution';
|
||||
case 'mcpToolCall':
|
||||
return 'McpToolCall';
|
||||
case 'fileChange':
|
||||
return 'FileChange';
|
||||
case 'reasoning':
|
||||
return 'Reasoning';
|
||||
case 'dynamicToolCall':
|
||||
return item.tool || 'DynamicToolCall';
|
||||
case 'collabAgentToolCall':
|
||||
return item.tool || 'CollabAgentToolCall';
|
||||
case 'webSearch':
|
||||
return 'WebSearch';
|
||||
case 'imageGeneration':
|
||||
return 'ImageGeneration';
|
||||
default:
|
||||
return item?.type || 'CodexAppItem';
|
||||
}
|
||||
}
|
||||
|
||||
function itemInput(item) {
|
||||
if (!item) return null;
|
||||
switch (item.type) {
|
||||
case 'commandExecution':
|
||||
return { command: item.command || '' };
|
||||
case 'mcpToolCall':
|
||||
return {
|
||||
server: item.server || '',
|
||||
tool: item.tool || '',
|
||||
arguments: item.arguments ?? null,
|
||||
};
|
||||
case 'fileChange':
|
||||
return { changes: item.changes || [] };
|
||||
case 'reasoning':
|
||||
return { content: item.content || [], summary: item.summary || [] };
|
||||
case 'dynamicToolCall':
|
||||
return { tool: item.tool || '', namespace: item.namespace || null, arguments: item.arguments ?? null };
|
||||
case 'collabAgentToolCall':
|
||||
return {
|
||||
tool: item.tool || '',
|
||||
prompt: item.prompt || null,
|
||||
receiverThreadIds: item.receiverThreadIds || [],
|
||||
agentsStates: item.agentsStates || {},
|
||||
};
|
||||
default:
|
||||
return truncate(item, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningTextFromItem(item) {
|
||||
const parts = [...(item?.summary || []), ...(item?.content || [])];
|
||||
return parts.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (typeof part?.text === 'string') return part.text;
|
||||
return '';
|
||||
}).filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function hasReasoningContent(item) {
|
||||
return Boolean(reasoningTextFromItem(item).trim());
|
||||
}
|
||||
|
||||
function itemMeta(item) {
|
||||
if (!item) return null;
|
||||
switch (item.type) {
|
||||
case 'commandExecution':
|
||||
return {
|
||||
kind: 'command_execution',
|
||||
title: 'Shell Command',
|
||||
subtitle: item.command || '',
|
||||
exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'mcpToolCall':
|
||||
return {
|
||||
kind: 'mcp_tool_call',
|
||||
title: 'MCP Tool',
|
||||
subtitle: [item.server, item.tool].filter(Boolean).join('.'),
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'fileChange':
|
||||
return {
|
||||
kind: 'file_change',
|
||||
title: 'File Change',
|
||||
subtitle: (item.changes || []).map((change) => change.path).filter(Boolean).join(', '),
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
kind: 'reasoning',
|
||||
title: 'Reasoning',
|
||||
subtitle: reasoningTextFromItem(item).replace(/\s+/g, ' ').slice(0, 120),
|
||||
status: item.status || null,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: itemKind(item),
|
||||
title: itemName(item),
|
||||
subtitle: item.tool || item.query || '',
|
||||
status: item.status || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyMcpResult(result) {
|
||||
if (!result) return '';
|
||||
if (Array.isArray(result.content)) {
|
||||
const text = result.content.map((part) => {
|
||||
if (typeof part?.text === 'string') return part.text;
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch {
|
||||
return String(part);
|
||||
}
|
||||
}).filter(Boolean).join('\n');
|
||||
if (text) return text;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}
|
||||
|
||||
function itemResult(item) {
|
||||
if (!item) return '';
|
||||
switch (item.type) {
|
||||
case 'commandExecution':
|
||||
return item.aggregatedOutput || '';
|
||||
case 'mcpToolCall':
|
||||
return item.error?.message || stringifyMcpResult(item.result);
|
||||
case 'fileChange':
|
||||
return JSON.stringify(item.changes || [], null, 2);
|
||||
case 'reasoning':
|
||||
return reasoningTextFromItem(item);
|
||||
case 'dynamicToolCall':
|
||||
return JSON.stringify({
|
||||
success: item.success ?? null,
|
||||
contentItems: item.contentItems || null,
|
||||
}, null, 2);
|
||||
case 'collabAgentToolCall':
|
||||
return JSON.stringify({
|
||||
status: item.status || null,
|
||||
receiverThreadIds: item.receiverThreadIds || [],
|
||||
agentsStates: item.agentsStates || {},
|
||||
}, null, 2);
|
||||
default:
|
||||
if (typeof item.text === 'string') return item.text;
|
||||
return JSON.stringify(truncate(item, 1200));
|
||||
}
|
||||
}
|
||||
|
||||
function ensureToolCall(entry, item, sessionId) {
|
||||
if (!item?.id) return null;
|
||||
const kind = itemKind(item);
|
||||
let toolCall = entry.toolCalls.find((tool) => tool.id === item.id);
|
||||
if (toolCall) {
|
||||
toolCall.name = itemName(item);
|
||||
toolCall.kind = kind;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta || null;
|
||||
if (toolCall.input == null) toolCall.input = itemInput(item);
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
toolCall = {
|
||||
name: itemName(item),
|
||||
id: item.id,
|
||||
kind,
|
||||
meta: itemMeta(item),
|
||||
input: itemInput(item),
|
||||
done: false,
|
||||
};
|
||||
entry.toolCalls.push(toolCall);
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_start',
|
||||
name: toolCall.name,
|
||||
toolUseId: toolCall.id,
|
||||
input: toolCall.input,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
function appendAgentText(entry, itemId, text) {
|
||||
const nextText = String(text || '');
|
||||
if (!nextText) return '';
|
||||
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
|
||||
const currentItemText = entry.agentMessageItems.get(itemId) || '';
|
||||
entry.agentMessageItems.set(itemId, currentItemText + nextText);
|
||||
entry.fullText = (entry.fullText || '') + nextText;
|
||||
return nextText;
|
||||
}
|
||||
|
||||
function appendAgentCompletedText(entry, item) {
|
||||
const text = String(item?.text || '');
|
||||
if (!text) return '';
|
||||
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
|
||||
const currentItemText = entry.agentMessageItems.get(item.id) || '';
|
||||
if (currentItemText && text.startsWith(currentItemText)) {
|
||||
const remainder = text.slice(currentItemText.length);
|
||||
entry.agentMessageItems.set(item.id, text);
|
||||
entry.fullText = (entry.fullText || '') + remainder;
|
||||
return remainder;
|
||||
}
|
||||
if (currentItemText === text) return '';
|
||||
entry.agentMessageItems.set(item.id, text);
|
||||
entry.fullText = (entry.fullText || '') + text;
|
||||
return text;
|
||||
}
|
||||
|
||||
function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {
|
||||
if (!itemId) return;
|
||||
let toolCall = entry.toolCalls.find((tool) => tool.id === itemId);
|
||||
if (!toolCall) {
|
||||
toolCall = {
|
||||
name: patch.name || 'CodexAppItem',
|
||||
id: itemId,
|
||||
kind: patch.kind || 'codex_app_item',
|
||||
meta: patch.meta || null,
|
||||
input: patch.input || null,
|
||||
done: false,
|
||||
};
|
||||
entry.toolCalls.push(toolCall);
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_start',
|
||||
name: toolCall.name,
|
||||
toolUseId: toolCall.id,
|
||||
input: toolCall.input,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
}
|
||||
if (patch.name) toolCall.name = patch.name;
|
||||
if (patch.kind) toolCall.kind = patch.kind;
|
||||
if (patch.meta) toolCall.meta = patch.meta;
|
||||
if (patch.input !== undefined) toolCall.input = patch.input;
|
||||
toolCall.done = done;
|
||||
toolCall.result = result;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: done ? 'tool_end' : 'tool_update',
|
||||
toolUseId: itemId,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
result,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
}
|
||||
|
||||
function updateUsage(sessionId, entry, usage) {
|
||||
const total = usage?.total || usage || null;
|
||||
if (!total) return;
|
||||
const session = loadSession(sessionId);
|
||||
if (!session) return;
|
||||
session.totalUsage = {
|
||||
inputTokens: total.inputTokens || 0,
|
||||
cachedInputTokens: total.cachedInputTokens || 0,
|
||||
outputTokens: total.outputTokens || 0,
|
||||
};
|
||||
entry.lastUsage = session.totalUsage;
|
||||
saveSession(session);
|
||||
sendRuntime(entry, sessionId, { type: 'usage', totalUsage: session.totalUsage });
|
||||
}
|
||||
|
||||
function processCodexAppNotification(entry, notification, sessionId) {
|
||||
if (!entry || !notification?.method) return { done: false };
|
||||
const method = notification.method;
|
||||
const params = notification.params || {};
|
||||
|
||||
if (params.threadId && !entry.threadId) entry.threadId = params.threadId;
|
||||
if (params.turnId && !entry.turnId) entry.turnId = params.turnId;
|
||||
|
||||
switch (method) {
|
||||
case 'turn/started':
|
||||
if (params.turn?.id) entry.turnId = params.turn.id;
|
||||
return { done: false };
|
||||
|
||||
case 'item/started': {
|
||||
const item = params.item;
|
||||
if (!item || !item.id || item.type === 'agentMessage' || item.type === 'userMessage') return { done: false };
|
||||
if (item.type === 'reasoning' && !hasReasoningContent(item)) return { done: false };
|
||||
ensureToolCall(entry, item, sessionId);
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/agentMessage/delta': {
|
||||
const chunk = appendAgentText(entry, params.itemId || 'agent-message', params.delta || '');
|
||||
if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/commandExecution/outputDelta': {
|
||||
const itemId = params.itemId;
|
||||
const current = entry.toolOutputDeltas?.get(itemId) || '';
|
||||
const next = current + String(params.delta || '');
|
||||
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
|
||||
entry.toolOutputDeltas.set(itemId, next);
|
||||
updateToolResult(entry, sessionId, itemId, next, false, {
|
||||
name: 'CommandExecution',
|
||||
kind: 'command_execution',
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/fileChange/patchUpdated': {
|
||||
updateToolResult(entry, sessionId, params.itemId, JSON.stringify(params.changes || [], null, 2), false, {
|
||||
name: 'FileChange',
|
||||
kind: 'file_change',
|
||||
input: { changes: params.changes || [] },
|
||||
meta: {
|
||||
kind: 'file_change',
|
||||
title: 'File Change',
|
||||
subtitle: (params.changes || []).map((change) => change.path).filter(Boolean).join(', '),
|
||||
status: 'inProgress',
|
||||
},
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/mcpToolCall/progress': {
|
||||
updateToolResult(entry, sessionId, params.itemId, params.message || '', false, {
|
||||
name: 'McpToolCall',
|
||||
kind: 'mcp_tool_call',
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/reasoning/summaryTextDelta':
|
||||
case 'item/reasoning/textDelta': {
|
||||
const itemId = params.itemId;
|
||||
const delta = String(params.delta || '');
|
||||
if (!itemId || !delta) return { done: false };
|
||||
const current = entry.toolOutputDeltas?.get(itemId) || '';
|
||||
const next = current + delta;
|
||||
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
|
||||
entry.toolOutputDeltas.set(itemId, next);
|
||||
updateToolResult(entry, sessionId, itemId, next, false, {
|
||||
name: 'Reasoning',
|
||||
kind: 'reasoning',
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'item/completed': {
|
||||
const item = params.item;
|
||||
if (!item || !item.id) return { done: false };
|
||||
if (item.type === 'agentMessage') {
|
||||
const chunk = appendAgentCompletedText(entry, item);
|
||||
if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
|
||||
return { done: false };
|
||||
}
|
||||
if (item.type === 'userMessage') return { done: false };
|
||||
if (item.type === 'reasoning') {
|
||||
const result = (itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '').slice(0, 4000);
|
||||
if (!result.trim()) return { done: false };
|
||||
const toolCall = ensureToolCall(entry, item, sessionId);
|
||||
if (!toolCall) return { done: false };
|
||||
toolCall.done = true;
|
||||
toolCall.result = result;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_end',
|
||||
toolUseId: toolCall.id,
|
||||
result,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
const toolCall = ensureToolCall(entry, item, sessionId);
|
||||
if (!toolCall) return { done: false };
|
||||
const result = itemResult(item).slice(0, 4000);
|
||||
toolCall.done = true;
|
||||
toolCall.result = result;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_end',
|
||||
toolUseId: toolCall.id,
|
||||
result,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
case 'thread/tokenUsage/updated':
|
||||
updateUsage(sessionId, entry, params.tokenUsage);
|
||||
return { done: false };
|
||||
|
||||
case 'turn/completed': {
|
||||
if (params.turn?.id) entry.turnId = params.turn.id;
|
||||
entry.turnStatus = params.turn?.status || 'completed';
|
||||
if (params.turn?.status === 'failed') {
|
||||
entry.lastError = params.turn?.error?.message || 'Codex App 任务失败';
|
||||
}
|
||||
return { done: true };
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'guardianWarning':
|
||||
case 'configWarning':
|
||||
case 'deprecationNotice': {
|
||||
const message = params.message || params.title || '';
|
||||
if (message) {
|
||||
if (method === 'error') entry.lastError = message;
|
||||
sendRuntime(entry, sessionId, { type: 'system_message', message });
|
||||
}
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
default:
|
||||
return { done: false };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processCodexAppNotification,
|
||||
updateUsage,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createCodexAppRuntime };
|
||||
220
lib/codex-app-server-client.js
Normal file
220
lib/codex-app-server-client.js
Normal file
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
const readline = require('readline');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function createCodexAppServerClient(options = {}) {
|
||||
const command = options.command || 'codex';
|
||||
const args = Array.isArray(options.args) && options.args.length > 0
|
||||
? options.args.slice()
|
||||
: ['app-server', '--stdio'];
|
||||
const env = options.env || process.env;
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const clientInfo = options.clientInfo || {
|
||||
name: 'ccweb_codexapp',
|
||||
title: 'CC-Web Codex App',
|
||||
version: '0.1.0',
|
||||
};
|
||||
const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {};
|
||||
const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null;
|
||||
const onExit = typeof options.onExit === 'function' ? options.onExit : () => {};
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const postInitialize = typeof options.postInitialize === 'function' ? options.postInitialize : null;
|
||||
|
||||
let proc = null;
|
||||
let rl = null;
|
||||
let nextId = 1;
|
||||
let initPromise = null;
|
||||
let exited = false;
|
||||
const pending = new Map();
|
||||
|
||||
function rejectAllPending(err) {
|
||||
for (const [, pendingRequest] of pending) {
|
||||
clearTimeout(pendingRequest.timer);
|
||||
pendingRequest.reject(err);
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
function sendRaw(message) {
|
||||
if (!proc || !proc.stdin || proc.stdin.destroyed) {
|
||||
throw new Error('Codex app-server 未启动。');
|
||||
}
|
||||
proc.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function respondToServerRequest(id, result, error) {
|
||||
try {
|
||||
if (error) {
|
||||
sendRaw({ id, error });
|
||||
} else {
|
||||
sendRaw({ id, result: result || {} });
|
||||
}
|
||||
} catch (err) {
|
||||
onLog('WARN', 'codex_app_server_response_failed', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerRequest(message) {
|
||||
const id = message.id;
|
||||
const method = message.method;
|
||||
const params = message.params || {};
|
||||
if (onServerRequest) {
|
||||
Promise.resolve()
|
||||
.then(() => onServerRequest({ method, params, id }))
|
||||
.then((result) => respondToServerRequest(id, result || {}))
|
||||
.catch((err) => respondToServerRequest(id, null, {
|
||||
code: -32603,
|
||||
message: err?.message || 'cc-web 无法处理 Codex app-server 请求。',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
respondToServerRequest(id, null, {
|
||||
code: -32601,
|
||||
message: `cc-web 暂不支持 Codex app-server 请求: ${method}`,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(line) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
onLog('WARN', 'codex_app_server_invalid_json', { line: String(line || '').slice(0, 200) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'id')) {
|
||||
const pendingRequest = pending.get(message.id);
|
||||
if (pendingRequest) {
|
||||
pending.delete(message.id);
|
||||
clearTimeout(pendingRequest.timer);
|
||||
if (message.error) {
|
||||
const err = new Error(message.error.message || 'Codex app-server 请求失败。');
|
||||
err.code = message.error.code;
|
||||
err.data = message.error.data;
|
||||
pendingRequest.reject(err);
|
||||
} else {
|
||||
pendingRequest.resolve(message.result || {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method) {
|
||||
handleServerRequest(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
onNotification(message);
|
||||
}
|
||||
}
|
||||
|
||||
function request(method, params = {}, timeoutMs = 300000) {
|
||||
const id = nextId++;
|
||||
const message = { id, method, params };
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Codex app-server 请求超时: ${method}`));
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer, method });
|
||||
try {
|
||||
sendRaw(message);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notification(method, params = {}) {
|
||||
sendRaw({ method, params });
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (initPromise) return initPromise;
|
||||
exited = false;
|
||||
proc = spawn(command, args, {
|
||||
env,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 4000) stderr = stderr.slice(-4000);
|
||||
});
|
||||
|
||||
rl = readline.createInterface({ input: proc.stdout });
|
||||
rl.on('line', handleMessage);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
exited = true;
|
||||
if (rl) rl.close();
|
||||
const err = new Error(`Codex app-server 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`);
|
||||
err.exitCode = code;
|
||||
err.signal = signal;
|
||||
err.stderr = stderr;
|
||||
rejectAllPending(err);
|
||||
onExit({ code, signal, stderr });
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
rejectAllPending(err);
|
||||
onExit({ code: null, signal: null, stderr: err.message });
|
||||
});
|
||||
|
||||
initPromise = request('initialize', {
|
||||
clientInfo,
|
||||
capabilities: { experimentalApi: true },
|
||||
}, 30000)
|
||||
.then(async (result) => {
|
||||
notification('initialized', {});
|
||||
if (postInitialize) await postInitialize({ request, notification, onLog });
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
stop();
|
||||
throw err;
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
initPromise = null;
|
||||
if (rl) {
|
||||
try { rl.close(); } catch {}
|
||||
rl = null;
|
||||
}
|
||||
if (proc && !exited) {
|
||||
try { proc.kill('SIGTERM'); } catch {}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (proc && !proc.killed) proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 3000);
|
||||
}
|
||||
proc = null;
|
||||
rejectAllPending(new Error('Codex app-server 已停止。'));
|
||||
}
|
||||
|
||||
function isRunning() {
|
||||
return !!proc && !exited;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
request,
|
||||
notification,
|
||||
isRunning,
|
||||
pid: () => proc?.pid || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createCodexAppServerClient };
|
||||
Reference in New Issue
Block a user