Files
cc-web/lib/javascript-session-runtime.js

854 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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_childrenidle 会经过稳定窗口复核以抑制等待态登记前的瞬时空闲。',
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 稳定转为 idlewaiting_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,
};