854 lines
38 KiB
JavaScript
854 lines
38 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const { spawn } = require('child_process');
|
||
|
||
const PACKAGE_NAME = '@ccweb/session';
|
||
const PACKAGE_VERSION = '1.1.0';
|
||
const DEFAULT_LOG_TAIL_BYTES = 64 * 1024;
|
||
const MAX_SCRIPT_SOURCE_BYTES = 1024 * 1024;
|
||
const DEFAULT_RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||
|
||
const SCRIPT_TOOL_NAMES = Object.freeze([
|
||
'ccweb_create_javascript_script',
|
||
'ccweb_write_javascript_script',
|
||
'ccweb_run_javascript_script',
|
||
'ccweb_get_javascript_script_run',
|
||
'ccweb_stop_javascript_script',
|
||
'ccweb_javascript_session_api',
|
||
]);
|
||
|
||
const SCRIPT_SESSION_TOOL_NAMES = Object.freeze([
|
||
'ccweb_script_get_current_conversation_id',
|
||
'ccweb_script_create_conversation',
|
||
'ccweb_script_send_message',
|
||
'ccweb_script_select_semantic_branch',
|
||
'ccweb_script_get_last_message',
|
||
'ccweb_script_get_conversation_status',
|
||
'ccweb_script_get_child_conversation_ids',
|
||
]);
|
||
|
||
const SCRIPT_TOOL_DEFINITIONS = [
|
||
{
|
||
name: 'ccweb_create_javascript_script',
|
||
description: '在当前来源对话 cwd 下的 .ccweb/scripts/ 创建一个新的 JavaScript ESM 脚本文件。仅允许相对 .js 文件名;文件已存在时返回 script_exists。',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { name: { type: 'string', description: '脚本相对路径,必须以 .js 结尾且不能路径穿越。' } },
|
||
required: ['name'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'ccweb_write_javascript_script',
|
||
description: '原子覆盖当前来源对话 cwd 下 .ccweb/scripts/ 内的 JavaScript 脚本内容;脚本使用 ESM。',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string', description: '脚本相对路径,必须以 .js 结尾。' },
|
||
content: { type: 'string', description: '完整脚本内容。' },
|
||
},
|
||
required: ['name', 'content'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'ccweb_run_javascript_script',
|
||
description: '异步启动当前来源对话 .ccweb/scripts/ 内的 JavaScript 脚本,立即返回 runId;使用 ccweb_get_javascript_script_run 查询结果,使用 ccweb_stop_javascript_script 停止。',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { name: { type: 'string', description: '脚本相对路径。' } },
|
||
required: ['name'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'ccweb_get_javascript_script_run',
|
||
description: '查询脚本 runId 的状态和日志。默认返回 stdout/stderr 尾部;可用 stream、offset、limit 分段读取完整日志。',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
runId: { type: 'string' },
|
||
stream: { type: 'string', enum: ['stdout', 'stderr'] },
|
||
offset: { type: 'integer', minimum: 0 },
|
||
limit: { type: 'integer', minimum: 1 },
|
||
},
|
||
required: ['runId'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'ccweb_stop_javascript_script',
|
||
description: '主动停止脚本。先优雅终止,2 秒后仍未退出则强制终止;主动停止不向来源对话插入通知。',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { runId: { type: 'string' } },
|
||
required: ['runId'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'ccweb_javascript_session_api',
|
||
description: '返回 @ccweb/session 标准包公开函数的完整结构化使用说明。',
|
||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||
},
|
||
];
|
||
|
||
function stableError(code, message, details = {}) {
|
||
return { ok: false, code, message, ...details };
|
||
}
|
||
|
||
function errorFromPayload(payload) {
|
||
const error = new Error(String(payload?.message || payload?.code || 'ccweb 脚本调用失败'));
|
||
error.code = String(payload?.code || 'script_call_failed');
|
||
error.details = payload && typeof payload === 'object' ? { ...payload } : {};
|
||
delete error.details.ok;
|
||
delete error.details.code;
|
||
delete error.details.message;
|
||
return error;
|
||
}
|
||
|
||
function packageIndexSource() {
|
||
return `import http from 'node:http';
|
||
import https from 'node:https';
|
||
|
||
const DEFAULT_URL = process.env.CC_WEB_SCRIPT_MCP_URL || process.env.CC_WEB_MCP_URL || '';
|
||
const RUN_ID = process.env.CC_WEB_SCRIPT_RUN_ID || '';
|
||
const TOKEN = process.env.CC_WEB_SCRIPT_MCP_TOKEN || '';
|
||
const SOURCE_ID = process.env.CC_WEB_SOURCE_SESSION_ID || '';
|
||
const CONVERSATION_STATUSES = new Set(['running', 'waiting_for_children', 'idle']);
|
||
const EVENT_POLL_INTERVAL_MS = 100;
|
||
const IDLE_EVENT_STABILITY_MS = 250;
|
||
|
||
function errorFromPayload(payload) {
|
||
const error = new Error(String(payload?.message || payload?.code || 'ccweb 脚本调用失败'));
|
||
error.code = String(payload?.code || 'script_call_failed');
|
||
error.details = payload && typeof payload === 'object' ? { ...payload } : {};
|
||
delete error.details.ok;
|
||
delete error.details.code;
|
||
delete error.details.message;
|
||
return error;
|
||
}
|
||
|
||
function localError(code, message, details = {}) {
|
||
const error = new Error(message);
|
||
error.code = code;
|
||
error.details = details;
|
||
return error;
|
||
}
|
||
|
||
function reportAsyncError(error) {
|
||
setTimeout(() => { throw error; }, 0);
|
||
}
|
||
|
||
function call(tool, args = {}) {
|
||
const urlText = DEFAULT_URL;
|
||
if (!urlText || !TOKEN || !RUN_ID || !SOURCE_ID) {
|
||
const error = new Error('ccweb JavaScript 会话包运行上下文不完整。');
|
||
error.code = 'script_context_missing';
|
||
error.details = { tool };
|
||
return Promise.reject(error);
|
||
}
|
||
let url;
|
||
try { url = new URL(urlText); } catch (cause) {
|
||
const error = new Error('ccweb JavaScript 会话 MCP 地址无效。');
|
||
error.code = 'script_mcp_bad_url';
|
||
error.details = { cause: cause?.message || String(cause || '') };
|
||
return Promise.reject(error);
|
||
}
|
||
const body = JSON.stringify({ tool, args, sourceSessionId: SOURCE_ID, scriptRunId: RUN_ID });
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
return new Promise((resolve, reject) => {
|
||
const request = transport.request(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
'X-CC-Web-MCP-Token': TOKEN,
|
||
},
|
||
}, (response) => {
|
||
let data = '';
|
||
response.setEncoding('utf8');
|
||
response.on('data', (chunk) => { data += chunk; });
|
||
response.on('end', () => {
|
||
let payload;
|
||
try { payload = JSON.parse(data || '{}'); } catch (cause) {
|
||
const error = new Error('ccweb JavaScript 会话 MCP 返回了无效 JSON。');
|
||
error.code = 'script_mcp_bad_response';
|
||
error.details = { statusCode: response.statusCode, cause: cause?.message || String(cause || '') };
|
||
reject(error);
|
||
return;
|
||
}
|
||
if (response.statusCode < 200 || response.statusCode >= 300 || payload?.ok === false) {
|
||
const error = errorFromPayload(payload);
|
||
error.details = { ...error.details, statusCode: response.statusCode };
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve(payload);
|
||
});
|
||
});
|
||
request.on('error', (cause) => {
|
||
const error = new Error(cause?.message || 'ccweb JavaScript 会话 MCP 请求失败。');
|
||
error.code = 'script_mcp_request_failed';
|
||
error.details = { tool };
|
||
reject(error);
|
||
});
|
||
request.write(body);
|
||
request.end();
|
||
});
|
||
}
|
||
|
||
export async function getCurrentConversationId() {
|
||
const result = await call('ccweb_script_get_current_conversation_id');
|
||
return result.conversationId;
|
||
}
|
||
export async function createConversation(prompt) {
|
||
const result = await call('ccweb_script_create_conversation', { prompt });
|
||
return result.conversationId;
|
||
}
|
||
export async function sendMessage(conversationId, prompt) {
|
||
const result = await call('ccweb_script_send_message', { conversationId, prompt });
|
||
return result.message;
|
||
}
|
||
export async function selectSemanticBranch(conversationId, semantics) {
|
||
const result = await call('ccweb_script_select_semantic_branch', { conversationId, semantics });
|
||
return result.branch;
|
||
}
|
||
export async function getLastMessage(conversationId) {
|
||
const result = await call('ccweb_script_get_last_message', { conversationId });
|
||
return result.message;
|
||
}
|
||
export async function getConversationStatus(conversationId) {
|
||
const result = await call('ccweb_script_get_conversation_status', { conversationId });
|
||
if (!CONVERSATION_STATUSES.has(result.status)) {
|
||
throw localError('conversation_status_invalid', 'ccweb 返回了未知的会话状态。', { conversationId, status: result.status });
|
||
}
|
||
return result.status;
|
||
}
|
||
export async function getChildConversationIds(conversationId) {
|
||
const result = await call('ccweb_script_get_child_conversation_ids', { conversationId });
|
||
if (!Array.isArray(result.childConversationIds) || result.childConversationIds.some((item) => typeof item !== 'string')) {
|
||
throw localError('conversation_children_invalid', 'ccweb 返回了无效的子对话 ID 数组。', { conversationId });
|
||
}
|
||
return result.childConversationIds;
|
||
}
|
||
export async function onConversationEvent(conversationId, event, listener) {
|
||
if (event !== 'idle') {
|
||
throw localError('conversation_event_invalid', '当前只支持监听 idle 事件。', { conversationId, event });
|
||
}
|
||
if (typeof listener !== 'function') {
|
||
throw localError('conversation_listener_invalid', 'listener 必须是函数。', { conversationId, event });
|
||
}
|
||
|
||
let active = true;
|
||
let timer = null;
|
||
const initialStatus = await getConversationStatus(conversationId);
|
||
let lastActiveStatus = initialStatus === 'idle' ? null : initialStatus;
|
||
let idleCandidateAt = null;
|
||
|
||
const unsubscribe = () => {
|
||
if (!active) return;
|
||
active = false;
|
||
if (timer) clearTimeout(timer);
|
||
timer = null;
|
||
};
|
||
|
||
const schedule = (poll) => {
|
||
if (!active) return;
|
||
timer = setTimeout(poll, EVENT_POLL_INTERVAL_MS);
|
||
};
|
||
|
||
const poll = async () => {
|
||
if (!active) return;
|
||
try {
|
||
const status = await getConversationStatus(conversationId);
|
||
if (!active) return;
|
||
if (status === 'idle') {
|
||
if (lastActiveStatus) {
|
||
const now = Date.now();
|
||
if (idleCandidateAt === null) idleCandidateAt = now;
|
||
if (now - idleCandidateAt >= IDLE_EVENT_STABILITY_MS) {
|
||
const previousStatus = lastActiveStatus;
|
||
lastActiveStatus = null;
|
||
idleCandidateAt = null;
|
||
try {
|
||
Promise.resolve(listener({
|
||
conversationId,
|
||
event: 'idle',
|
||
previousStatus,
|
||
status: 'idle',
|
||
occurredAt: new Date().toISOString(),
|
||
})).catch(reportAsyncError);
|
||
} catch (error) {
|
||
reportAsyncError(error);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
lastActiveStatus = status;
|
||
idleCandidateAt = null;
|
||
}
|
||
schedule(poll);
|
||
} catch (error) {
|
||
unsubscribe();
|
||
reportAsyncError(error);
|
||
}
|
||
};
|
||
|
||
schedule(poll);
|
||
return unsubscribe;
|
||
}
|
||
`;
|
||
}
|
||
|
||
function packageJsonSource() {
|
||
return JSON.stringify({ name: 'ccweb-script-runtime', private: true, type: 'module', dependencies: { [PACKAGE_NAME]: PACKAGE_VERSION } }, null, 2) + '\n';
|
||
}
|
||
|
||
function packageModuleJsonSource() {
|
||
return JSON.stringify({ name: PACKAGE_NAME, version: PACKAGE_VERSION, private: true, type: 'module', main: './index.js', exports: './index.js' }, null, 2) + '\n';
|
||
}
|
||
|
||
function writeGeneratedFileIfChanged(filePath, content) {
|
||
try {
|
||
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) return;
|
||
} catch {}
|
||
const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||
try {
|
||
fs.writeFileSync(temp, content);
|
||
fs.renameSync(temp, filePath);
|
||
} catch (error) {
|
||
try { fs.unlinkSync(temp); } catch {}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function createJavascriptSessionRuntime(deps = {}) {
|
||
const activeRuns = new Map();
|
||
const sessionsDir = deps.sessionsDir || process.cwd();
|
||
const runRetentionMs = Number.isFinite(Number(deps.runRetentionMs)) && Number(deps.runRetentionMs) > 0
|
||
? Number(deps.runRetentionMs)
|
||
: DEFAULT_RUN_RETENTION_MS;
|
||
|
||
function sourceSession(sourceSessionId) {
|
||
const id = String(sourceSessionId || '').trim();
|
||
const session = typeof deps.loadSession === 'function' ? deps.loadSession(id) : null;
|
||
return session ? { ok: true, id, session } : stableError('source_not_found', '来源对话不存在。', { sourceConversationId: id });
|
||
}
|
||
|
||
function scriptsDirFor(sourceSessionId, create = false) {
|
||
const source = sourceSession(sourceSessionId);
|
||
if (!source.ok) return source;
|
||
const cwd = path.resolve(source.session.cwd || process.cwd());
|
||
const scriptsDir = path.join(cwd, '.ccweb', 'scripts');
|
||
if (create) {
|
||
try { fs.mkdirSync(path.join(scriptsDir, 'node_modules', '@ccweb', 'session'), { recursive: true }); } catch (error) {
|
||
return stableError('script_directory_failed', `无法准备脚本目录:${error.message}`, { sourceConversationId: source.id });
|
||
}
|
||
const packageJsonPath = path.join(scriptsDir, 'package.json');
|
||
const modulePackageJsonPath = path.join(scriptsDir, 'node_modules', '@ccweb', 'session', 'package.json');
|
||
const packageIndexPath = path.join(scriptsDir, 'node_modules', '@ccweb', 'session', 'index.js');
|
||
try {
|
||
if (!fs.existsSync(packageJsonPath)) fs.writeFileSync(packageJsonPath, packageJsonSource(), { flag: 'wx' });
|
||
writeGeneratedFileIfChanged(modulePackageJsonPath, packageModuleJsonSource());
|
||
writeGeneratedFileIfChanged(packageIndexPath, packageIndexSource());
|
||
} catch (error) {
|
||
return stableError('script_package_failed', `无法注入 ${PACKAGE_NAME}:${error.message}`, { sourceConversationId: source.id });
|
||
}
|
||
}
|
||
return { ok: true, id: source.id, session: source.session, scriptsDir };
|
||
}
|
||
|
||
function resolveScriptPath(sourceSessionId, rawName, options = {}) {
|
||
const name = String(rawName || '').trim().replace(/\\/g, '/');
|
||
if (!name || name.startsWith('/') || name.includes('\0') || path.posix.isAbsolute(name) || name.split('/').includes('..') || !name.endsWith('.js')) {
|
||
return stableError('invalid_script_name', '脚本名必须是 .ccweb/scripts/ 下的相对 .js 路径。', { name });
|
||
}
|
||
const root = scriptsDirFor(sourceSessionId, options.create === true);
|
||
if (!root.ok) return root;
|
||
const resolved = path.resolve(root.scriptsDir, ...name.split('/'));
|
||
if (resolved !== root.scriptsDir && !resolved.startsWith(`${root.scriptsDir}${path.sep}`)) {
|
||
return stableError('invalid_script_path', '脚本路径越界。', { name });
|
||
}
|
||
if (!options.allowNested && path.dirname(path.relative(root.scriptsDir, resolved)) !== '.') {
|
||
return stableError('invalid_script_name', '第一版只允许脚本目录下的相对文件名。', { name });
|
||
}
|
||
return { ...root, name, scriptPath: resolved };
|
||
}
|
||
|
||
function runDir(entry) {
|
||
return path.join(entry.scriptsDir, '.runs', entry.runId);
|
||
}
|
||
|
||
function metadataPath(entry) { return path.join(runDir(entry), 'run.json'); }
|
||
function stdoutPath(entry) { return path.join(runDir(entry), 'stdout.log'); }
|
||
function stderrPath(entry) { return path.join(runDir(entry), 'stderr.log'); }
|
||
|
||
function persist(entry) {
|
||
const dir = runDir(entry);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const record = { ...entry };
|
||
delete record.process;
|
||
delete record.token;
|
||
delete record.stdoutStream;
|
||
delete record.stderrStream;
|
||
delete record.forceKillTimer;
|
||
delete record.finishing;
|
||
const temp = `${metadataPath(entry)}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||
try {
|
||
fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
|
||
fs.renameSync(temp, metadataPath(entry));
|
||
} catch (error) {
|
||
try { fs.unlinkSync(temp); } catch {}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function tailFile(filePath, limit = DEFAULT_LOG_TAIL_BYTES) {
|
||
try {
|
||
const stat = fs.statSync(filePath);
|
||
const size = stat.size;
|
||
const start = Math.max(0, size - limit);
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const buffer = Buffer.alloc(size - start);
|
||
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||
fs.closeSync(fd);
|
||
return { text: buffer.toString('utf8'), size, offset: start, truncated: start > 0 };
|
||
} catch { return { text: '', size: 0, offset: 0, truncated: false }; }
|
||
}
|
||
|
||
function rangeFile(filePath, offset = 0, limit = DEFAULT_LOG_TAIL_BYTES) {
|
||
try {
|
||
const stat = fs.statSync(filePath);
|
||
const safeOffset = Math.max(0, Math.min(stat.size, Number.parseInt(String(offset), 10) || 0));
|
||
const safeLimit = Math.max(1, Math.min(1024 * 1024, Number.parseInt(String(limit), 10) || DEFAULT_LOG_TAIL_BYTES));
|
||
const length = Math.min(safeLimit, stat.size - safeOffset);
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const buffer = Buffer.alloc(length);
|
||
if (length > 0) fs.readSync(fd, buffer, 0, length, safeOffset);
|
||
fs.closeSync(fd);
|
||
return { text: buffer.toString('utf8'), size: stat.size, offset: safeOffset, limit: safeLimit, truncated: safeOffset + length < stat.size };
|
||
} catch { return { text: '', size: 0, offset: 0, limit, truncated: false }; }
|
||
}
|
||
|
||
function runRecordForSource(sourceSessionId, runId) {
|
||
const id = String(runId || '').trim();
|
||
if (!id || !/^[0-9a-f-]{20,}$/i.test(id)) return stableError('invalid_run_id', 'runId 无效。', { runId: id });
|
||
const root = scriptsDirFor(sourceSessionId, false);
|
||
if (!root.ok) return root;
|
||
const metadata = path.join(root.scriptsDir, '.runs', id, 'run.json');
|
||
if (!fs.existsSync(metadata)) return stableError('script_run_not_found', '未找到脚本运行记录。', { runId: id });
|
||
try {
|
||
const record = JSON.parse(fs.readFileSync(metadata, 'utf8'));
|
||
if (record.sourceConversationId !== root.id) return stableError('script_run_forbidden', '无权访问该脚本运行记录。', { runId: id });
|
||
return { ok: true, record, scriptsDir: root.scriptsDir, metadata };
|
||
} catch (error) {
|
||
return stableError('script_run_corrupt', `脚本运行记录损坏:${error.message}`, { runId: id });
|
||
}
|
||
}
|
||
|
||
function createScript(args = {}, sourceSessionId = '') {
|
||
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||
if (!resolved.ok) return resolved;
|
||
try {
|
||
fs.mkdirSync(path.dirname(resolved.scriptPath), { recursive: true });
|
||
const stat = fs.existsSync(resolved.scriptPath) ? fs.lstatSync(resolved.scriptPath) : null;
|
||
if (stat) return stableError('script_exists', '脚本文件已存在。', { name: resolved.name, path: resolved.scriptPath });
|
||
fs.writeFileSync(resolved.scriptPath, '', { flag: 'wx' });
|
||
return { ok: true, name: resolved.name, path: resolved.scriptPath, scriptsDir: resolved.scriptsDir };
|
||
} catch (error) {
|
||
return stableError('script_create_failed', `创建脚本失败:${error.message}`, { name: resolved.name });
|
||
}
|
||
}
|
||
|
||
function writeScript(args = {}, sourceSessionId = '') {
|
||
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||
if (!resolved.ok) return resolved;
|
||
const content = typeof args.content === 'string' ? args.content : '';
|
||
const contentBytes = Buffer.byteLength(content, 'utf8');
|
||
if (contentBytes > MAX_SCRIPT_SOURCE_BYTES) {
|
||
return stableError('script_content_too_large', '脚本内容不能超过 1 MiB。', {
|
||
name: resolved.name,
|
||
bytes: contentBytes,
|
||
maxBytes: MAX_SCRIPT_SOURCE_BYTES,
|
||
});
|
||
}
|
||
try {
|
||
if (fs.existsSync(resolved.scriptPath) && fs.lstatSync(resolved.scriptPath).isSymbolicLink()) {
|
||
return stableError('script_symlink_forbidden', '不允许写入符号链接脚本。', { name: resolved.name });
|
||
}
|
||
fs.mkdirSync(path.dirname(resolved.scriptPath), { recursive: true });
|
||
const temp = `${resolved.scriptPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||
try {
|
||
fs.writeFileSync(temp, content);
|
||
fs.renameSync(temp, resolved.scriptPath);
|
||
} catch (error) {
|
||
try { fs.unlinkSync(temp); } catch {}
|
||
throw error;
|
||
}
|
||
return { ok: true, name: resolved.name, path: resolved.scriptPath, bytes: Buffer.byteLength(content) };
|
||
} catch (error) {
|
||
return stableError('script_write_failed', `写入脚本失败:${error.message}`, { name: resolved.name });
|
||
}
|
||
}
|
||
|
||
function finishRun(entry, code, signal, forcedReason = '') {
|
||
if (!entry || entry.finishedAt || entry.finishing) return;
|
||
entry.finishing = true;
|
||
if (entry.forceKillTimer) {
|
||
clearTimeout(entry.forceKillTimer);
|
||
entry.forceKillTimer = null;
|
||
}
|
||
entry.exitCode = typeof code === 'number' ? code : null;
|
||
entry.signal = signal || null;
|
||
entry.finishedAt = new Date().toISOString();
|
||
entry.durationMs = Math.max(0, new Date(entry.finishedAt).getTime() - new Date(entry.startedAt).getTime());
|
||
entry.terminationReason = forcedReason || entry.terminationReason || null;
|
||
if (entry.stopRequested) entry.status = 'killed';
|
||
else entry.status = code === 0 && !signal ? 'succeeded' : 'failed';
|
||
entry.tokenRevoked = true;
|
||
let streamsToClose = 0;
|
||
let finalized = false;
|
||
const finalize = () => {
|
||
if (finalized) return;
|
||
finalized = true;
|
||
entry.finishing = false;
|
||
entry.stderrPreview = tailFile(stderrPath(entry), 8 * 1024).text;
|
||
activeRuns.delete(entry.runId);
|
||
persist(entry);
|
||
if (entry.status !== 'succeeded' && !entry.stopRequested && typeof deps.notifyFailure === 'function') {
|
||
try { deps.notifyFailure(entry.sourceConversationId, entry); } catch {}
|
||
}
|
||
};
|
||
const streamClosed = () => {
|
||
streamsToClose -= 1;
|
||
if (streamsToClose <= 0) finalize();
|
||
};
|
||
for (const stream of [entry.stdoutStream, entry.stderrStream]) {
|
||
if (!stream) continue;
|
||
streamsToClose += 1;
|
||
stream.end(streamClosed);
|
||
}
|
||
if (streamsToClose === 0) finalize();
|
||
}
|
||
|
||
function runScript(args = {}, sourceSessionId = '') {
|
||
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||
if (!resolved.ok) return resolved;
|
||
if (!fs.existsSync(resolved.scriptPath)) return stableError('script_not_found', '脚本文件不存在,请先创建或写入脚本。', { name: resolved.name });
|
||
try {
|
||
const stat = fs.lstatSync(resolved.scriptPath);
|
||
if (stat.isSymbolicLink()) return stableError('script_symlink_forbidden', '不允许执行符号链接脚本。', { name: resolved.name });
|
||
if (!stat.isFile()) return stableError('script_not_file', '脚本路径必须指向普通文件。', { name: resolved.name });
|
||
} catch (error) {
|
||
return stableError('script_not_found', `无法读取脚本文件:${error.message}`, { name: resolved.name });
|
||
}
|
||
if (activeRunsHasSourceScript(sourceSessionId, resolved.scriptPath)) {
|
||
return stableError('script_already_running', '同一脚本已经在运行中。', { name: resolved.name });
|
||
}
|
||
const runId = crypto.randomUUID();
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
const entry = {
|
||
runId,
|
||
sourceConversationId: resolved.id,
|
||
scriptsDir: resolved.scriptsDir,
|
||
name: resolved.name,
|
||
scriptPath: resolved.scriptPath,
|
||
status: 'running',
|
||
startedAt: new Date().toISOString(),
|
||
finishedAt: null,
|
||
durationMs: null,
|
||
exitCode: null,
|
||
signal: null,
|
||
terminationReason: null,
|
||
stopRequested: false,
|
||
tokenRevoked: false,
|
||
pid: null,
|
||
};
|
||
try {
|
||
fs.mkdirSync(path.join(resolved.scriptsDir, '.runs', runId), { recursive: true });
|
||
entry.stdoutStream = fs.createWriteStream(stdoutPath(entry), { flags: 'a' });
|
||
entry.stderrStream = fs.createWriteStream(stderrPath(entry), { flags: 'a' });
|
||
entry.token = token;
|
||
const env = {
|
||
...process.env,
|
||
CC_WEB_SCRIPT_MCP_URL: String(deps.internalMcpUrl || ''),
|
||
CC_WEB_SCRIPT_MCP_TOKEN: token,
|
||
CC_WEB_SCRIPT_RUN_ID: runId,
|
||
CC_WEB_SOURCE_SESSION_ID: resolved.id,
|
||
};
|
||
const child = spawn(process.execPath, [resolved.scriptPath], {
|
||
cwd: resolved.scriptsDir,
|
||
env,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
});
|
||
entry.process = child;
|
||
entry.pid = child.pid;
|
||
child.stdout.pipe(entry.stdoutStream);
|
||
child.stderr.pipe(entry.stderrStream);
|
||
activeRuns.set(runId, entry);
|
||
persist(entry);
|
||
child.on('error', (error) => finishRun(entry, null, null, `spawn_error:${error.message}`));
|
||
// close 事件发生在 stdout/stderr 流关闭后,确保 stderrPreview 已经包含最后输出。
|
||
child.on('close', (code, signal) => finishRun(entry, code, signal));
|
||
return { ok: true, runId, name: resolved.name, status: entry.status, startedAt: entry.startedAt };
|
||
} catch (error) {
|
||
entry.status = 'failed';
|
||
entry.finishedAt = new Date().toISOString();
|
||
entry.terminationReason = `spawn_error:${error.message}`;
|
||
entry.tokenRevoked = true;
|
||
try { persist(entry); } catch {}
|
||
if (entry.stdoutStream) entry.stdoutStream.end();
|
||
if (entry.stderrStream) entry.stderrStream.end();
|
||
if (typeof deps.notifyFailure === 'function') deps.notifyFailure(entry.sourceConversationId, entry);
|
||
return stableError('script_start_failed', `启动脚本失败:${error.message}`, { runId, name: resolved.name });
|
||
}
|
||
}
|
||
|
||
function activeRunsHasSourceScript(sourceSessionId, scriptPath) {
|
||
for (const entry of activeRuns.values()) {
|
||
if (entry.sourceConversationId === String(sourceSessionId || '').trim() && entry.scriptPath === scriptPath) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function getRun(args = {}, sourceSessionId = '') {
|
||
const result = runRecordForSource(sourceSessionId, args.runId);
|
||
if (!result.ok) return result;
|
||
const record = result.record;
|
||
const stream = args.stream === 'stdout' || args.stream === 'stderr' ? args.stream : null;
|
||
const hasRange = Object.prototype.hasOwnProperty.call(args, 'offset') || Object.prototype.hasOwnProperty.call(args, 'limit');
|
||
const readOne = (which) => hasRange
|
||
? rangeFile(path.join(result.scriptsDir, '.runs', record.runId, `${which}.log`), args.offset, args.limit)
|
||
: tailFile(path.join(result.scriptsDir, '.runs', record.runId, `${which}.log`), DEFAULT_LOG_TAIL_BYTES);
|
||
const stdout = stream === 'stderr' ? null : readOne('stdout');
|
||
const stderr = stream === 'stdout' ? null : readOne('stderr');
|
||
return {
|
||
ok: true,
|
||
...record,
|
||
pid: record.pid || null,
|
||
stdout: stdout?.text || '',
|
||
stderr: stderr?.text || '',
|
||
stdoutBytes: stdout?.size || 0,
|
||
stderrBytes: stderr?.size || 0,
|
||
stdoutOffset: stdout?.offset || 0,
|
||
stderrOffset: stderr?.offset || 0,
|
||
stdoutTruncated: !!stdout?.truncated,
|
||
stderrTruncated: !!stderr?.truncated,
|
||
};
|
||
}
|
||
|
||
function stopScript(args = {}, sourceSessionId = '') {
|
||
const id = String(args.runId || '').trim();
|
||
const active = activeRuns.get(id);
|
||
if (!active) {
|
||
const result = runRecordForSource(sourceSessionId, id);
|
||
if (!result.ok) return result;
|
||
if (result.record.status !== 'running') return { ok: true, runId: id, status: result.record.status, alreadyFinished: true };
|
||
return stableError('script_process_unavailable', '脚本记录仍显示运行中,但进程已不在当前服务内。', { runId: id });
|
||
}
|
||
if (active.sourceConversationId !== String(sourceSessionId || '').trim()) return stableError('script_run_forbidden', '无权停止该脚本运行。', { runId: id });
|
||
if (active.finishedAt) return { ok: true, runId: id, status: active.status, alreadyFinished: true };
|
||
if (active.stopRequested) return { ok: true, runId: id, status: 'stopping', alreadyStopping: true };
|
||
active.stopRequested = true;
|
||
active.terminationReason = 'stopped_by_request';
|
||
persist(active);
|
||
try { active.process.kill('SIGTERM'); } catch {}
|
||
active.forceKillTimer = setTimeout(() => {
|
||
if (!active.finishedAt) {
|
||
try { active.process.kill('SIGKILL'); } catch {}
|
||
active.terminationReason = 'forced_after_grace_period';
|
||
persist(active);
|
||
}
|
||
}, 2000);
|
||
return { ok: true, runId: id, status: 'stopping' };
|
||
}
|
||
|
||
function authorizeScriptCall({ runId, sourceSessionId, token } = {}) {
|
||
const id = String(runId || '').trim();
|
||
const entry = activeRuns.get(id);
|
||
if (!entry || entry.finishedAt || entry.tokenRevoked || entry.token !== String(token || '').trim()) {
|
||
const record = runRecordForSource(sourceSessionId, id);
|
||
if (record.ok && record.record.status === 'killed') {
|
||
return stableError('script_stopped', '脚本已停止,运行凭据已吊销。', { runId: id });
|
||
}
|
||
return stableError('script_expired', '脚本运行凭据已失效。', { runId: id });
|
||
}
|
||
if (entry.sourceConversationId !== String(sourceSessionId || '').trim()) return stableError('script_run_forbidden', '脚本来源对话不匹配。', { runId: entry.runId });
|
||
return { ok: true, entry };
|
||
}
|
||
|
||
function getApiManifest() {
|
||
return {
|
||
ok: true,
|
||
packageName: PACKAGE_NAME,
|
||
version: PACKAGE_VERSION,
|
||
moduleFormat: 'ESM',
|
||
importExample: `import { getCurrentConversationId, createConversation, sendMessage, selectSemanticBranch, getLastMessage, getConversationStatus, getChildConversationIds, onConversationEvent } from '${PACKAGE_NAME}';`,
|
||
statusValues: ['running', 'waiting_for_children', 'idle'],
|
||
functions: [
|
||
{
|
||
name: 'getCurrentConversationId',
|
||
parameters: [],
|
||
returns: 'Promise<string>',
|
||
description: '返回当前脚本来源对话的 ID,不发起新的会话执行。',
|
||
errors: ['script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'createConversation',
|
||
parameters: [{ name: 'prompt', type: 'string' }],
|
||
returns: 'Promise<string>',
|
||
description: '创建持久对话并投递首条提示词;等待首轮处理结束后,仅返回新对话 ID。',
|
||
errors: ['empty_prompt', 'conversation_create_failed', 'conversation_not_found', 'conversation_execution_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'sendMessage',
|
||
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'prompt', type: 'string' }],
|
||
returns: 'Promise<string>',
|
||
description: '向指定对话插入提示词,等待该轮处理结束后返回最后一条助手文本;允许目标为当前来源对话。',
|
||
errors: ['conversation_not_found', 'empty_prompt', 'send_message_failed', 'conversation_execution_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'selectSemanticBranch',
|
||
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'semantics', type: 'string[]' }],
|
||
returns: 'Promise<string>',
|
||
description: '仅依据指定对话最后一条助手消息,由独立只读判断器选择语义数组中的一项;非候选结果最多重试三次。',
|
||
errors: ['conversation_not_found', 'semantic_branch_invalid', 'last_message_not_found', 'semantic_judge_unavailable', 'semantic_judge_invalid_output', 'semantic_judge_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'getLastMessage',
|
||
parameters: [{ name: 'conversationId', type: 'string' }],
|
||
returns: 'Promise<string>',
|
||
description: '返回指定对话最后一条已完成助手消息的纯文本;不会返回消息对象或中间状态。',
|
||
errors: ['conversation_not_found', 'last_message_not_found', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'getConversationStatus',
|
||
parameters: [{ name: 'conversationId', type: 'string' }],
|
||
returns: "Promise<'running' | 'waiting_for_children' | 'idle'>",
|
||
description: '返回会话稳定状态;running 优先于 waiting_for_children,idle 会经过稳定窗口复核以抑制等待态登记前的瞬时空闲。',
|
||
errors: ['conversation_not_found', 'conversation_status_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'getChildConversationIds',
|
||
parameters: [{ name: 'conversationId', type: 'string' }],
|
||
returns: 'Promise<string[]>',
|
||
description: '返回指定对话通过 MCP 创建的直接持久子对话 ID,按会话列表顺序排列,不递归包含孙对话。',
|
||
errors: ['conversation_not_found', 'conversation_children_read_failed', 'conversation_children_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
{
|
||
name: 'onConversationEvent',
|
||
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'event', type: "'idle'" }, { name: 'listener', type: 'function' }],
|
||
returns: 'Promise<() => void>',
|
||
description: '监听指定会话从 running 或 waiting_for_children 稳定转为 idle;waiting_for_children 和瞬时 idle 不触发。resolve 为注销函数,注销后不再产生新回调。',
|
||
listenerPayload: '{ conversationId, event, previousStatus, status, occurredAt }',
|
||
example: "const unsubscribe = await onConversationEvent(id, 'idle', async (event) => { /* 判断并继续 */ });\\nunsubscribe();",
|
||
errors: ['conversation_not_found', 'conversation_event_invalid', 'conversation_listener_invalid', 'conversation_status_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||
},
|
||
],
|
||
limitations: [
|
||
'8 个函数均可通过 async/await 使用;onConversationEvent resolve 为同步注销函数。',
|
||
'onConversationEvent 当前只支持 idle;活动监听会保持 Node.js 脚本进程存活,必须在不再监听时调用注销函数。',
|
||
'idle 事件仅在活动状态稳定结束后触发,waiting_for_children 期间和等待态登记前的瞬时 idle 均不触发。',
|
||
'函数调用依赖当前脚本运行上下文;上下文失效或 MCP 请求失败时会以 Error reject。',
|
||
],
|
||
errors: {
|
||
shape: 'Error',
|
||
fields: ['code', 'message', 'details'],
|
||
commonCodes: ['conversation_not_found', 'conversation_execution_failed', 'last_message_not_found', 'conversation_event_invalid', 'conversation_listener_invalid', 'conversation_status_invalid', 'conversation_children_read_failed', 'semantic_branch_invalid', 'semantic_judge_failed', 'script_expired', 'script_stopped'],
|
||
},
|
||
};
|
||
}
|
||
|
||
function recover() {
|
||
try {
|
||
for (const file of fs.readdirSync(sessionsDir).filter((item) => item.endsWith('.json'))) {
|
||
let session;
|
||
try { session = deps.loadSession(path.basename(file, '.json')); } catch { session = null; }
|
||
if (!session?.cwd) continue;
|
||
const runsRoot = path.join(path.resolve(session.cwd), '.ccweb', 'scripts', '.runs');
|
||
if (!fs.existsSync(runsRoot)) continue;
|
||
for (const runId of fs.readdirSync(runsRoot)) {
|
||
const metadata = path.join(runsRoot, runId, 'run.json');
|
||
if (!fs.existsSync(metadata)) continue;
|
||
try {
|
||
const record = JSON.parse(fs.readFileSync(metadata, 'utf8'));
|
||
if (record.status !== 'running' && record.finishedAt) {
|
||
const finishedAtMs = new Date(record.finishedAt).getTime();
|
||
if (Number.isFinite(finishedAtMs) && Date.now() - finishedAtMs > runRetentionMs) {
|
||
fs.rmSync(path.join(runsRoot, runId), { recursive: true, force: true });
|
||
continue;
|
||
}
|
||
}
|
||
if (record.status !== 'running') continue;
|
||
const recoveredPid = Number.parseInt(String(record.pid || ''), 10);
|
||
let matchesScript = false;
|
||
if (Number.isInteger(recoveredPid) && recoveredPid > 0 && record.scriptPath) {
|
||
try {
|
||
const cmdline = fs.readFileSync(`/proc/${recoveredPid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
||
matchesScript = cmdline.includes(String(record.scriptPath)) || cmdline.includes(String(record.name || ''));
|
||
} catch {}
|
||
}
|
||
if (matchesScript) {
|
||
try { process.kill(recoveredPid, 'SIGTERM'); } catch {}
|
||
setTimeout(() => {
|
||
try { process.kill(recoveredPid, 'SIGKILL'); } catch {}
|
||
}, 2000).unref?.();
|
||
}
|
||
record.status = 'killed';
|
||
record.finishedAt = new Date().toISOString();
|
||
record.durationMs = Math.max(0, new Date(record.finishedAt).getTime() - new Date(record.startedAt).getTime());
|
||
record.terminationReason = 'server_restarted';
|
||
record.tokenRevoked = true;
|
||
const temp = `${metadata}.${process.pid}.tmp`;
|
||
fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
|
||
fs.renameSync(temp, metadata);
|
||
if (typeof deps.notifyFailure === 'function') deps.notifyFailure(record.sourceConversationId || session.id, record);
|
||
} catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
return {
|
||
createScript,
|
||
writeScript,
|
||
runScript,
|
||
getRun,
|
||
stopScript,
|
||
authorizeScriptCall,
|
||
getApiManifest,
|
||
recover,
|
||
handleSessionCall(tool, args, sourceSessionId) {
|
||
const method = {
|
||
ccweb_script_get_current_conversation_id: 'getCurrentConversationId',
|
||
ccweb_script_create_conversation: 'createConversation',
|
||
ccweb_script_send_message: 'sendMessage',
|
||
ccweb_script_select_semantic_branch: 'selectSemanticBranch',
|
||
ccweb_script_get_last_message: 'getLastMessage',
|
||
ccweb_script_get_conversation_status: 'getConversationStatus',
|
||
ccweb_script_get_child_conversation_ids: 'getChildConversationIds',
|
||
}[tool];
|
||
if (!method || typeof deps.sessionApi?.[method] !== 'function') return stableError('unknown_script_api', `未知脚本会话能力:${tool}`);
|
||
return deps.sessionApi[method](args || {}, sourceSessionId);
|
||
},
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
PACKAGE_NAME,
|
||
PACKAGE_VERSION,
|
||
SCRIPT_TOOL_NAMES,
|
||
SCRIPT_SESSION_TOOL_NAMES,
|
||
SCRIPT_TOOL_DEFINITIONS,
|
||
createJavascriptSessionRuntime,
|
||
packageIndexSource,
|
||
packageJsonSource,
|
||
};
|