'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.0.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', ]); 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 标准包、Promise 会话函数和 JavaScript 脚本 MCP 工具的完整结构化使用说明。', 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 || ''; 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; } `; } 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 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' }); if (!fs.existsSync(modulePackageJsonPath)) fs.writeFileSync(modulePackageJsonPath, packageModuleJsonSource(), { flag: 'wx' }); if (!fs.existsSync(packageIndexPath)) fs.writeFileSync(packageIndexPath, packageIndexSource(), { flag: 'wx' }); } 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 } from '${PACKAGE_NAME}';`, functions: [ { name: 'getCurrentConversationId', parameters: [], returns: 'Promise', 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', 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', 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', 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', description: '返回指定对话最后一条已完成助手消息的纯文本;不会返回消息对象或中间状态。', errors: ['conversation_not_found', 'last_message_not_found', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'], }, ], limitations: [ '5 个函数均返回 Promise,脚本可直接使用 async/await。', '函数调用依赖当前脚本运行上下文;上下文失效或 MCP 请求失败时会以 Error reject。', ], errors: { shape: 'Error', fields: ['code', 'message', 'details'], commonCodes: ['conversation_not_found', 'conversation_execution_failed', 'last_message_not_found', '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', }[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, };