feat: add cross conversation messaging
This commit is contained in:
@@ -9,6 +9,10 @@ function createAgentRuntime(deps) {
|
||||
getDefaultCodexModel,
|
||||
loadCodexConfig,
|
||||
prepareCodexCustomRuntime,
|
||||
ccwebMcpServerPath,
|
||||
internalMcpUrl,
|
||||
internalMcpToken,
|
||||
nodePath,
|
||||
wsSend,
|
||||
truncateObj,
|
||||
sanitizeToolInput,
|
||||
@@ -18,6 +22,39 @@ function createAgentRuntime(deps) {
|
||||
getRuntimeSessionId,
|
||||
} = deps;
|
||||
|
||||
function tomlString(value) {
|
||||
return JSON.stringify(String(value || ''));
|
||||
}
|
||||
|
||||
function tomlStringArray(values) {
|
||||
return `[${values.map((value) => tomlString(value)).join(',')}]`;
|
||||
}
|
||||
|
||||
function createCcwebMcpEnv(session, options = {}) {
|
||||
if (!ccwebMcpServerPath || !internalMcpUrl || !internalMcpToken || !session?.id) return null;
|
||||
const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
|
||||
const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
|
||||
return {
|
||||
CC_WEB_MCP_URL: internalMcpUrl,
|
||||
CC_WEB_MCP_TOKEN: internalMcpToken,
|
||||
CC_WEB_SOURCE_SESSION_ID: session.id,
|
||||
CC_WEB_CROSS_HOP_COUNT: String(hopCount),
|
||||
};
|
||||
}
|
||||
|
||||
function appendCcwebMcpConfig(args, mcpEnv) {
|
||||
if (!mcpEnv) return;
|
||||
const envVars = Object.keys(mcpEnv);
|
||||
args.push(
|
||||
'-c', 'mcp_servers.ccweb.type="stdio"',
|
||||
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
|
||||
'-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerPath])}`,
|
||||
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
|
||||
'-c', 'mcp_servers.ccweb.startup_timeout_sec=10',
|
||||
'-c', 'mcp_servers.ccweb.tool_timeout_sec=60'
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeSpawnSpec(session, options = {}) {
|
||||
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
@@ -75,6 +112,8 @@ function createAgentRuntime(deps) {
|
||||
const runtimeId = getRuntimeSessionId(session);
|
||||
const args = ['exec'];
|
||||
args.push('--json', '--skip-git-repo-check');
|
||||
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
||||
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
||||
|
||||
const permMode = session.permissionMode || 'yolo';
|
||||
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
||||
@@ -126,7 +165,7 @@ function createAgentRuntime(deps) {
|
||||
args.push('-');
|
||||
}
|
||||
|
||||
const env = { ...processEnv };
|
||||
const env = { ...processEnv, ...(ccwebMcpEnv || {}) };
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
|
||||
251
lib/ccweb-mcp-server.js
Normal file
251
lib/ccweb-mcp-server.js
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const SERVER_INFO = {
|
||||
name: 'ccweb',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'ccweb_list_conversations',
|
||||
description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agent: {
|
||||
type: 'string',
|
||||
enum: ['claude', 'codex'],
|
||||
description: '可选。只返回指定 Agent 的对话。',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['all', 'running', 'idle'],
|
||||
description: '可选。按运行状态过滤,默认 all。',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: '可选。最多返回多少条,默认 50。',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ccweb_send_message',
|
||||
description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetConversationId: {
|
||||
type: 'string',
|
||||
description: '目标对话 ID。',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '要发送到目标对话的纯文本消息。',
|
||||
},
|
||||
},
|
||||
required: ['targetConversationId', 'content'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function writeMessage(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function jsonRpcResult(id, result) {
|
||||
writeMessage({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function jsonRpcError(id, code, message, data) {
|
||||
const error = { code, message };
|
||||
if (data !== undefined) error.data = data;
|
||||
writeMessage({ jsonrpc: '2.0', id, error });
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function callCcweb(tool, args) {
|
||||
return new Promise((resolve) => {
|
||||
const urlText = String(process.env.CC_WEB_MCP_URL || '').trim();
|
||||
const token = String(process.env.CC_WEB_MCP_TOKEN || '').trim();
|
||||
if (!urlText || !token) {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: 'mcp_not_configured',
|
||||
message: 'ccweb MCP 环境变量未配置完整。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(urlText);
|
||||
} catch {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: 'mcp_bad_url',
|
||||
message: 'ccweb MCP 内部地址无效。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
tool,
|
||||
args: args && typeof args === 'object' ? args : {},
|
||||
sourceSessionId: process.env.CC_WEB_SOURCE_SESSION_ID || '',
|
||||
sourceHopCount: parseInteger(process.env.CC_WEB_CROSS_HOP_COUNT, 0),
|
||||
});
|
||||
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'X-CC-Web-MCP-Token': token,
|
||||
},
|
||||
timeout: 60000,
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const payload = JSON.parse(data || '{}');
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(payload);
|
||||
} else {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: payload.code || 'ccweb_http_error',
|
||||
message: payload.message || `ccweb 内部接口返回 HTTP ${res.statusCode}`,
|
||||
statusCode: res.statusCode,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: 'ccweb_bad_response',
|
||||
message: 'ccweb 内部接口返回了无法解析的响应。',
|
||||
statusCode: res.statusCode,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
ok: false,
|
||||
code: 'ccweb_timeout',
|
||||
message: 'ccweb 内部接口调用超时。',
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: 'ccweb_request_failed',
|
||||
message: err.message || 'ccweb 内部接口调用失败。',
|
||||
});
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function toolResponse(payload) {
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: payload,
|
||||
isError: !payload?.ok,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const { id, method } = message;
|
||||
const hasId = Object.prototype.hasOwnProperty.call(message, 'id');
|
||||
|
||||
if (!hasId) return;
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
jsonRpcResult(id, {
|
||||
protocolVersion: message.params?.protocolVersion || '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: SERVER_INFO,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ping':
|
||||
jsonRpcResult(id, {});
|
||||
break;
|
||||
case 'tools/list':
|
||||
jsonRpcResult(id, { tools: TOOLS });
|
||||
break;
|
||||
case 'tools/call': {
|
||||
const name = String(message.params?.name || '');
|
||||
const args = message.params?.arguments || {};
|
||||
if (!TOOLS.some((tool) => tool.name === name)) {
|
||||
jsonRpcResult(id, toolResponse({
|
||||
ok: false,
|
||||
code: 'unknown_tool',
|
||||
message: `未知工具: ${name}`,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
const payload = await callCcweb(name, args);
|
||||
jsonRpcResult(id, toolResponse(payload));
|
||||
break;
|
||||
}
|
||||
case 'resources/list':
|
||||
jsonRpcResult(id, { resources: [] });
|
||||
break;
|
||||
case 'prompts/list':
|
||||
jsonRpcResult(id, { prompts: [] });
|
||||
break;
|
||||
default:
|
||||
jsonRpcError(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (err) {
|
||||
jsonRpcError(id, -32603, err.message || 'Internal error');
|
||||
}
|
||||
}
|
||||
|
||||
let lineBuffer = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
lineBuffer += chunk;
|
||||
let index;
|
||||
while ((index = lineBuffer.indexOf('\n')) >= 0) {
|
||||
const line = lineBuffer.slice(0, index).trim();
|
||||
lineBuffer = lineBuffer.slice(index + 1);
|
||||
if (!line) continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch (err) {
|
||||
jsonRpcError(null, -32700, 'Parse error', err.message);
|
||||
continue;
|
||||
}
|
||||
handleRequest(message);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user