diff --git a/bridge.js b/bridge.js new file mode 100644 index 0000000..2519d76 --- /dev/null +++ b/bridge.js @@ -0,0 +1,487 @@ +const http = require('node:http'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = __dirname; +const statePath = path.join(root, 'state'); +const logsPath = path.join(statePath, 'logs'); +const configPath = path.join(root, 'config.json'); +const heartbeatPath = path.join(statePath, 'bridge.json'); +const tokenPath = path.join(statePath, 'mcp-token'); +const protocolVersion = '2025-06-18'; +const supportedVersions = new Set(['2024-11-05', '2025-03-26', protocolVersion]); +const maxBodyBytes = 1000000; + +let bridgeToken; +let heartbeatTimer; +let server; +let startedAt; +let shuttingDown = false; + +function loadConfig() { + try { + return JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + return {}; + } +} + +function bridgePort() { + const port = Number(loadConfig().mcpBridge?.port); + return Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : 5758; +} + +function dashboardPort() { + const port = Number(loadConfig().dashboardPort); + return Number.isInteger(port) && port > 0 && port < 65536 ? port : 5757; +} + +function log(message) { + try { + fs.mkdirSync(logsPath, { recursive: true }); + fs.appendFileSync(path.join(logsPath, 'bridge.log'), `${new Date().toISOString()} ${message}\n`, 'utf8'); + } catch { + } +} + +function storedToken() { + try { + return fs.readFileSync(tokenPath, 'utf8').trim(); + } catch { + return ''; + } +} + +function readToken() { + while (true) { + const existing = storedToken(); + + if (existing) { + return existing; + } + + try { + fs.unlinkSync(tokenPath); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + const token = crypto.randomBytes(32).toString('hex'); + fs.mkdirSync(statePath, { recursive: true }); + + try { + fs.writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + return token; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + } +} + +function writeHeartbeat() { + fs.mkdirSync(statePath, { recursive: true }); + fs.writeFileSync(heartbeatPath, `${JSON.stringify({ + pid: process.pid, + port: bridgePort(), + startedAt, + ts: new Date().toISOString(), + }, null, 2)}\n`, 'utf8'); +} + +function clearHeartbeat() { + try { + const heartbeat = JSON.parse(fs.readFileSync(heartbeatPath, 'utf8')); + + if (heartbeat.pid !== process.pid) { + return; + } + } catch { + } + + try { + fs.unlinkSync(heartbeatPath); + } catch { + } +} + +function sendJson(res, statusCode, value) { + const body = JSON.stringify(value); + res.writeHead(statusCode, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +function sendEmpty(res, statusCode, headers = {}) { + res.writeHead(statusCode, { 'cache-control': 'no-store', ...headers }); + res.end(); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + let body = ''; + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + size += Buffer.byteLength(chunk); + + if (size > maxBodyBytes) { + reject(new Error('Request body is too large.')); + req.destroy(); + return; + } + + body += chunk; + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Request body must be valid JSON.')); + } + }); + req.on('error', reject); + }); +} + +function rpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function rpcError(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function timingSafeEqual(left, right) { + if (!left || !right) { + return false; + } + + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function authorized(req, requestUrl) { + const header = req.headers.authorization; + const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header) : null; + const candidate = match ? match[1].trim() : requestUrl.searchParams.get('key') || ''; + + if (!candidate || !bridgeToken) { + return false; + } + + return timingSafeEqual(candidate, bridgeToken); +} + +function daemonRequest(requestPath, body) { + return new Promise((resolve, reject) => { + const data = body === undefined ? null : Buffer.from(JSON.stringify(body)); + const request = http.request({ + hostname: '127.0.0.1', + port: dashboardPort(), + path: requestPath, + method: data ? 'POST' : 'GET', + headers: data ? { + 'content-type': 'application/json', + 'content-length': data.length, + } : {}, + }, (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { text += chunk; }); + response.on('end', () => { + let value = null; + + try { + value = text ? JSON.parse(text) : null; + } catch { + value = null; + } + + resolve({ statusCode: response.statusCode || 500, value }); + }); + }); + + request.setTimeout(10000, () => request.destroy(new Error('Daemon request timed out.'))); + request.on('error', reject); + request.end(data || undefined); + }); +} + +function numeric(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function statusSnapshot(state) { + const stats = state && typeof state.stats === 'object' ? state.stats : {}; + const tasks = state && typeof state.tasks === 'object' ? state.tasks : {}; + const running = Array.isArray(tasks.running) ? tasks.running : []; + const recent = Array.isArray(tasks.recent) ? tasks.recent : []; + + return { + daemonAlive: state?.daemon?.alive === true, + counts: { + pending: numeric(stats.pending), + running: numeric(stats.running), + done: numeric(stats.done), + failed: numeric(stats.failed), + }, + runningTasks: running.map((task) => ({ + id: task.id, + title: task.title, + elapsed: numeric(task.elapsedMs), + })), + recentResults: recent.map((task) => ({ + id: task.id, + title: task.title, + status: task.status || task.result?.status || null, + })), + }; +} + +function toolResult(value) { + return { + content: [{ type: 'text', text: JSON.stringify(value) }], + structuredContent: value, + }; +} + +function toolFailure(message) { + return { + content: [{ type: 'text', text: message }], + isError: true, + }; +} + +const tools = [ + { + name: 'agentloop_status', + description: 'Read the current AgentLoop status.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true }, + }, + { + name: 'dispatch_task', + description: 'Dispatch a task to AgentLoop.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + prompt: { type: 'string' }, + engine: { type: 'string', enum: ['codex'] }, + }, + required: ['title', 'prompt'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'start_loop', + description: 'Start an AgentLoop project loop.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + maxCycles: { type: 'integer', minimum: 1, maximum: 10 }, + engine: { type: 'string', enum: ['codex'] }, + }, + required: ['project'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + }, +]; + +function daemonError(response) { + return response.value && typeof response.value.error === 'string' + ? response.value.error + : `Daemon request failed (${response.statusCode}).`; +} + +async function callTool(params) { + const name = params && typeof params.name === 'string' ? params.name : ''; + const args = params && params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) + ? params.arguments + : {}; + + if (name === 'agentloop_status') { + try { + const response = await daemonRequest('/api/state'); + return toolResult(statusSnapshot(response.statusCode === 200 ? response.value : null)); + } catch { + return toolResult(statusSnapshot(null)); + } + } + + if (name === 'dispatch_task') { + const response = await daemonRequest('/api/dispatch', { + title: args.title, + prompt: args.prompt, + engine: args.engine, + source: 'mcp', + }); + + return response.statusCode >= 200 && response.statusCode < 300 + ? toolResult({ id: response.value?.id }) + : toolFailure(daemonError(response)); + } + + if (name === 'start_loop') { + const response = await daemonRequest('/api/loop', { + project: args.project, + maxCycles: args.maxCycles, + engine: args.engine, + source: 'mcp', + }); + + return response.statusCode >= 200 && response.statusCode < 300 + ? toolResult({ id: response.value?.id }) + : toolFailure(daemonError(response)); + } + + return toolFailure(`Unknown tool: ${name}.`); +} + +async function handleMessage(message) { + if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return rpcError(null, -32600, 'Invalid Request.'); + } + + const notification = !Object.hasOwn(message, 'id'); + const id = notification ? null : message.id; + let result; + + try { + if (message.method === 'initialize') { + const requestedVersion = message.params?.protocolVersion; + result = { + protocolVersion: supportedVersions.has(requestedVersion) ? requestedVersion : protocolVersion, + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'agentloop', version: '0.1.0' }, + }; + } else if (message.method === 'notifications/initialized' || message.method === 'ping') { + result = {}; + } else if (message.method === 'tools/list') { + result = { tools }; + } else if (message.method === 'tools/call') { + result = await callTool(message.params); + } else if (message.method === 'resources/list') { + result = { resources: [] }; + } else if (message.method === 'prompts/list') { + result = { prompts: [] }; + } else { + return notification ? null : rpcError(id, -32601, 'Method not found.'); + } + } catch (error) { + return notification ? null : rpcError(id, -32603, error.message || 'Internal error.'); + } + + return notification ? null : rpcResult(id, result); +} + +async function handleRequest(req, res) { + const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + + if (!['/', '/mcp'].includes(requestUrl.pathname)) { + sendJson(res, 404, { error: 'Not found.' }); + return; + } + + if (req.method !== 'POST') { + sendEmpty(res, 405, { allow: 'POST' }); + return; + } + + if (!authorized(req, requestUrl)) { + sendJson(res, 401, rpcError(null, -32001, 'Unauthorized.')); + return; + } + + let payload; + + try { + payload = await readJsonBody(req); + } catch (error) { + sendJson(res, 400, rpcError(null, -32700, error.message)); + return; + } + + const batch = Array.isArray(payload); + + if (batch && payload.length === 0) { + sendJson(res, 400, rpcError(null, -32600, 'Invalid Request.')); + return; + } + + const messages = batch ? payload : [payload]; + const responses = []; + + for (const message of messages) { + const response = await handleMessage(message); + + if (response) { + responses.push(response); + } + } + + if (!responses.length) { + sendEmpty(res, 202); + return; + } + + sendJson(res, 200, batch ? responses : responses[0]); +} + +function shutdown() { + if (shuttingDown) { + return; + } + + shuttingDown = true; + clearInterval(heartbeatTimer); + clearHeartbeat(); + log('stopped'); + + if (!server) { + process.exit(0); + return; + } + + server.close(() => process.exit(0)); +} + +function start() { + bridgeToken = readToken(); + startedAt = new Date().toISOString(); + writeHeartbeat(); + server = http.createServer((req, res) => { + handleRequest(req, res).catch((error) => { + sendJson(res, 500, rpcError(null, -32603, error.message || 'Internal error.')); + }); + }); + server.on('error', (error) => { + clearHeartbeat(); + log(`error: ${error.message}`); + process.exitCode = 1; + }); + server.listen(bridgePort(), '127.0.0.1', () => { + writeHeartbeat(); + heartbeatTimer = setInterval(writeHeartbeat, 5000); + log('started'); + }); +} + +process.once('SIGINT', shutdown); +process.once('SIGTERM', shutdown); +start(); diff --git a/config.json b/config.json index 8e5dd76..0653ab9 100644 --- a/config.json +++ b/config.json @@ -2,5 +2,8 @@ "dashboardPort": 5757, "maxConcurrent": 2, "taskTimeoutMin": 45, - "defaultEngine": "codex" + "defaultEngine": "codex", + "mcpBridge": { + "port": 5758 + } } diff --git a/public/index.html b/public/index.html index f9829a5..cdbcb41 100644 --- a/public/index.html +++ b/public/index.html @@ -176,6 +176,28 @@ .chip b{font-family:var(--mono);font-weight:650;color:var(--text);font-variant-numeric:tabular-nums;font-size:12px} .hright{display:flex;align-items:center;gap:10px} #clock{font-family:var(--mono);font-size:12.5px;color:var(--text);letter-spacing:.04em;font-variant-numeric:tabular-nums} +/* connector */ +.connector{position:relative} +#connectorBtn{ + display:inline-flex;align-items:center;gap:6px;padding:4px 10px; + border:1px solid var(--border);background:var(--hover);color:var(--text); +} +#connectorBtn:hover{border-color:var(--accent-soft);background:var(--accent-dim)} +#connectorBtn i{width:6px;height:6px;border-radius:2px;background:var(--muted);flex:none} +#connectorBtn.live i{background:var(--ok);animation:breathe 1.8s ease-in-out infinite} +#connectorMenu{ + position:absolute;right:0;top:calc(100% + 10px);z-index:60;width:390px;max-width:calc(100vw - 24px); + background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow); +} +#connectorMenu[hidden]{display:none} +#connectorMenu:not([hidden]){animation:menuIn .16s ease} +.mcp-pophead{ + display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--border); + background:color-mix(in srgb,var(--text) 2.5%,transparent); +} +.mcp-poplabel{font:650 10.5px/1.4 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--muted)} +.mcp-pophead .spacer{flex:1} +.mcp-popbody{padding:8px} /* alive pill */ #pill{ display:inline-flex;align-items:center;gap:7px;padding:4px 10px;border-radius:5px; @@ -285,6 +307,14 @@ .chead .spacer{flex:1} .cbody{padding:8px} .cbody.pad{padding:14px 16px 16px} +/* ================= connector ================= */ +.mcp-row{display:flex;align-items:center;gap:8px;padding:7px 8px;border-bottom:1px solid var(--line-soft)} +.mcp-row:last-of-type{border-bottom:none} +.mcp-label{width:68px;flex:none;font:600 10px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.mcp-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 11px/1.5 var(--mono);color:var(--text)} +.mcp-token{color:var(--accent)} +.mcp-hint{padding:8px;color:var(--muted);font-size:11.5px;line-height:1.5} +.mcpbadge{margin-left:2px;color:var(--info);background:color-mix(in srgb,var(--info) 13%,transparent)} /* empty states */ .hush{ display:flex;align-items:center;gap:10px;padding:14px 10px;color:var(--muted);font-size:12.5px; @@ -564,6 +594,9 @@ #drawer,#newPanel{width:100vw;border-left:none} .brow{flex-direction:column} .brow .send{width:100%} + .mcp-row{align-items:flex-start;flex-wrap:wrap} + .mcp-label{padding-top:2px} + .mcp-value{white-space:normal;overflow-wrap:anywhere} input,select,textarea{font-size:16px} /* prevents iOS zoom-on-focus */ } @media (max-width:480px){ @@ -594,6 +627,31 @@ - spend today
+
+ + +
' + @@ -1274,7 +1425,7 @@

Messages

$('#cRecent').textContent = arr.length || ''; const sig = arr.map(t => [ t?.id, t?.title, t?.status ?? t?.result?.status, t?.summary ?? t?.result?.summary, - t?.reason ?? t?.result?.reason, t?.prompt, + t?.reason ?? t?.result?.reason, t?.prompt, t?.source, fmtRel(t?.finishedAt ?? t?.result?.finishedAt) ].join('|')).join('~'); if (!changed('rec', sig)) return; @@ -1288,7 +1439,7 @@

Messages

return '
' + '' + esc(pt) + '' + '
' + - '
' + esc(t?.title || 'untitled') + '' + id + '
' + + '
' + esc(t?.title || 'untitled') + (t?.source === 'mcp' ? 'MCP' : '') + '' + id + '
' + '
' + esc(sum || '(no summary)') + '' + '
' + (reason ? '
reason ' + esc(reason) + '
' : '') + @@ -1554,6 +1705,7 @@

Messages

buildThemeMenu(); if (THEME_IDS.indexOf(document.documentElement.getAttribute('data-theme')) < 0) setTheme('tokyo-night'); markTheme(); + $('#connectorBtn').addEventListener('click', () => connectorOpen($('#connectorMenu').hidden)); $('#themeBtn').addEventListener('click', () => menuOpen($('#themeMenu').hidden)); $('#themeMenu').addEventListener('click', e => { const it = e.target.closest('.titem'); @@ -1564,6 +1716,7 @@

Messages

}); document.addEventListener('click', e => { if (!e.target.closest('.themer')) menuOpen(false); + if (!e.target.closest('.connector')) connectorOpen(false); }); $('#runningList').addEventListener('click', e => { @@ -1609,6 +1762,10 @@

Messages

const b = e.target.closest('.bubble.linked'); if (b) jumpToBlocked(b.getAttribute('data-task')); }); + $('#mcpToggle').addEventListener('click', toggleBridge); + $('#mcpUrlCopy').addEventListener('click', e => copyText(bridgeConnectorUrl(), e.currentTarget)); + $('#mcpReveal').addEventListener('click', toggleMcpToken); + $('#mcpTokenCopy').addEventListener('click', e => copyText(bridgeInfo.token, e.currentTarget)); $('#dclose').addEventListener('click', closeDrawer); $('#newBtn').addEventListener('click', openNew); $('#queueNew').addEventListener('click', openNew); @@ -1617,6 +1774,7 @@

Messages

document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (!$('#themeMenu').hidden) { menuOpen(false); $('#themeBtn').focus({preventScroll: true}); } + else if (!$('#connectorMenu').hidden) { connectorOpen(false); $('#connectorBtn').focus({preventScroll: true}); } else if ($('#newPanel').classList.contains('open')) closeNew(); else closeDrawer(); }); @@ -1677,6 +1835,8 @@

Messages

if (document.visibilityState === 'visible') poll(); }); tick(); + renderBridge(); + loadBridge(); poll(); setInterval(poll, 2000); setInterval(tick, 1000); diff --git a/src/daemon.js b/src/daemon.js index 44818de..337e763 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -27,6 +27,7 @@ let daemonInfo; let server; let ticker; let stopping = false; +let bridgeChild; function taskTime(task, field) { const value = Date.parse(task[field]); @@ -230,6 +231,131 @@ function stopWorker(child) { } } +function bridgePort() { + const port = Number(store.config.mcpBridge?.port); + return Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : 5758; +} + +function readBridgeHeartbeat() { + try { + return JSON.parse(fs.readFileSync(store.paths.bridge, 'utf8')); + } catch { + return null; + } +} + +function clearBridgeHeartbeat(pid) { + const heartbeat = readBridgeHeartbeat(); + + if (pid && heartbeat && heartbeat.pid !== pid) { + return; + } + + try { + fs.unlinkSync(store.paths.bridge); + } catch { + } +} + +function bridgeRunning() { + return store.isAlive(readBridgeHeartbeat()); +} + +function recoverBridgeHeartbeat() { + const heartbeat = readBridgeHeartbeat(); + + if (heartbeat && !store.isAlive(heartbeat)) { + clearBridgeHeartbeat(heartbeat.pid); + } +} + +function readBridgeToken() { + try { + const token = fs.readFileSync(store.paths.mcpToken, 'utf8').trim(); + return token || null; + } catch { + return null; + } +} + +function bridgeDetails() { + const port = bridgePort(); + const token = readBridgeToken(); + const localEndpoint = `http://127.0.0.1:${port}/mcp`; + + return { + running: bridgeRunning(), + port, + localEndpoint, + connectorUrl: token ? `${localEndpoint}?key=${encodeURIComponent(token)}` : localEndpoint, + token, + }; +} + +function startBridge() { + if (bridgeRunning()) { + return true; + } + + recoverBridgeHeartbeat(); + + if (bridgeChild && bridgeChild.exitCode === null && !bridgeChild.killed) { + return true; + } + + try { + const child = spawn(process.execPath, [path.join(store.paths.root, 'bridge.js')], { + cwd: store.paths.root, + stdio: 'ignore', + windowsHide: true, + }); + + bridgeChild = child; + child.unref(); + child.once('error', (error) => { + console.error(`Bridge failed to start: ${error.message}`); + if (bridgeChild === child) { + bridgeChild = undefined; + } + }); + child.once('exit', () => { + clearBridgeHeartbeat(child.pid); + if (bridgeChild === child) { + bridgeChild = undefined; + } + }); + return true; + } catch (error) { + console.error(`Bridge failed to start: ${error.message}`); + return false; + } +} + +function stopBridge() { + const heartbeat = readBridgeHeartbeat(); + + if (!store.isAlive(heartbeat)) { + recoverBridgeHeartbeat(); + return false; + } + + const child = bridgeChild && bridgeChild.pid === heartbeat.pid + ? bridgeChild + : { + pid: heartbeat.pid, + kill(signal) { + process.kill(this.pid, signal); + }, + }; + + terminateWorker(child); + clearBridgeHeartbeat(heartbeat.pid); + if (bridgeChild === child) { + bridgeChild = undefined; + } + return true; +} + function readWorkerOutput(outputPath, fallback) { try { const output = fs.readFileSync(outputPath, 'utf8'); @@ -1275,6 +1401,9 @@ function daemonState() { startedAt: daemonInfo.startedAt, ts: new Date().toISOString(), }, + bridge: { + running: bridgeRunning(), + }, stats: { pending: pendingTasks.length, running: runningTasks.length, @@ -1342,6 +1471,10 @@ function serveDashboard(res) { } } +function taskSource(body) { + return body && body.source === 'mcp' ? 'mcp' : 'api'; +} + async function dispatch(req, res) { let body; @@ -1371,7 +1504,7 @@ async function dispatch(req, res) { cwd: body.cwd, title: body.title, priority: body.priority, - source: 'api', + source: taskSource(body), }); recordEvent('queue', { id: task.id }); @@ -1513,7 +1646,7 @@ async function createLoop(req, res) { engine, model: store.config.model, title: `loop: ${project}`, - source: 'api', + source: taskSource(body), }); recordEvent('loop_queued', { id: loop.id }); sendJson(res, 201, { id: loop.id }); @@ -1646,6 +1779,11 @@ async function handleRequest(req, res) { return; } + if (req.method === 'GET' && requestPath === '/api/bridge') { + sendJson(res, 200, bridgeDetails()); + return; + } + if (req.method === 'GET' && requestPath.startsWith('/api/log/')) { serveLog(res, requestPath, requestUrl); return; @@ -1661,6 +1799,17 @@ async function handleRequest(req, res) { return; } + if (req.method === 'POST' && requestPath === '/api/bridge/start') { + sendJson(res, 200, { running: startBridge() || bridgeRunning() }); + return; + } + + if (req.method === 'POST' && requestPath === '/api/bridge/stop') { + stopBridge(); + sendJson(res, 200, { running: bridgeRunning() }); + return; + } + if (req.method === 'POST' && requestPath === '/api/cancel') { await cancelTask(req, res); return; @@ -1744,6 +1893,7 @@ function recoverRunningTasks() { function stop() { stopping = true; + stopBridge(); const workers = [...activeWorkers.values()]; for (const worker of workers) { @@ -1776,6 +1926,7 @@ function start() { return; } + recoverBridgeHeartbeat(); recoverRunningTasks(); server = http.createServer((req, res) => { diff --git a/src/store.js b/src/store.js index 4e96fb3..97928db 100644 --- a/src/store.js +++ b/src/store.js @@ -16,6 +16,8 @@ const paths = { logs: path.join(root, 'state', 'logs'), events: path.join(root, 'state', 'events.ndjson'), daemon: path.join(root, 'state', 'daemon.json'), + bridge: path.join(root, 'state', 'bridge.json'), + mcpToken: path.join(root, 'state', 'mcp-token'), }; const defaults = { @@ -23,6 +25,7 @@ const defaults = { maxConcurrent: 2, taskTimeoutMin: 45, defaultEngine: 'codex', + mcpBridge: { port: 5758 }, }; function loadConfig() { @@ -39,6 +42,9 @@ function loadConfig() { maxConcurrent: loaded.maxConcurrent ?? defaults.maxConcurrent, taskTimeoutMin: loaded.taskTimeoutMin ?? defaults.taskTimeoutMin, defaultEngine: loaded.defaultEngine ?? defaults.defaultEngine, + mcpBridge: { + port: loaded.mcpBridge?.port ?? defaults.mcpBridge.port, + }, }; if (loaded.model) {