#!/usr/bin/env node /** * Persistent Chat Hub — 唯一 HTTP 进程(端口 13458) * 所有 Cursor 工作区的 MCP 进程共享本 Hub,避免多进程抢端口导致面板回复进错进程。 */ 'use strict'; const fs = require('fs'); const path = require('path'); const http = require('http'); const crypto = require('crypto'); const os = require('os'); const { URL } = require('url'); const HOME = process.env.HOME || process.env.USERPROFILE; const ROOT = path.join(HOME, '.persistent-chat-local'); const LOG_DIR = path.join(ROOT, 'logs'); const SESSIONS_FILE = path.join(ROOT, 'sessions.json'); const BINDINGS_FILE = path.join(ROOT, 'bindings.json'); const PLAN_BINDINGS_FILE = path.join(ROOT, 'plan-bindings.json'); const THREAD_BINDINGS_DIR = path.join(ROOT, 'thread-bindings'); const PANEL_HTML = path.join(__dirname, 'panel.html'); const HTTP_PORT = parseInt(process.env.PCHAT_HTTP_PORT || '13458', 10); const POLL_TIMEOUT_MS = 10000; const CONNECTION_KEEPALIVE_MS = 10000; /** 超过此时长无保活且未挂起 → 归档到 archive/ */ const STALE_KEEPALIVE_MS = 2 * 60 * 60 * 1000; const ARCHIVE_DIR = path.join(ROOT, 'archive'); const SETTINGS_FILE = path.join(ROOT, 'settings.json'); const DEFAULT_SETTINGS = { autoContinue: true, /** 全局默认续跑模式(单会话可覆盖):standard=复盘 | turbo=直通 | optimize=优化 */ autoContinueMode: 'standard', /** 单会话默认:自动续跑开、复盘模式 */ defaultSessionAutoContinue: true, defaultContinueMode: 'standard', autoContinueDelayMs: 10000, autoContinueText: '继续', turboContinueText: '【直通续跑】继续执行当前计划,直推开发直到目标完成,无需等复盘/追问/检索确认。', autoContinueMaxStreak: 0, /** 定时回复:0=自动(仅走 autoContinue 规则);>0 为固定间隔 */ timedReplyEnabled: true, defaultTimedReplyEnabled: true, timedReplyIntervalMs: 10000, connectionKeepAliveMs: 10000, timedReplyPresets: [0, 10000, 30000, 60000, 120000, 240000, 540000, 900000, 1800000], timedReplyText: '【定时回复】保持持久对话连接,若仍有任务请继续执行。', timedReplyTurboText: '【定时续跑】保持连接,继续执行当前计划,直推开发直到目标完成。', /** 为 true 时:▶=无 / 100% 仍发保活续跑,不因 idle 停 scheduleAutoContinue */ alwaysKeepAlive: true, keepAliveText: '【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。', keepAliveTurboText: '【保活续跑】保持连接,继续执行当前计划与学习清单,直推开发直到目标完成。', maxSessions: 30, maxIdleAgeDays: 14, }; for (const d of [ROOT, LOG_DIR, ARCHIVE_DIR]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} } const logFile = path.join(LOG_DIR, 'hub.log'); function log(...parts) { const line = `[${new Date().toISOString()}] ${parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' ')}\n`; try { fs.appendFileSync(logFile, line); } catch {} } const sessions = new Map(); /** token -> { message, prompt, title, createdAt, workspace, pid, pollers, autoTimer?, timedReplyTimer?, timedReplyFireAt? } */ const pendingWaits = new Map(); const TIMED_REPLY_MIN_MS = 5000; /** 全局唯一:只有这个 token 允许自动续跑,避免多标签乱继续 */ let activeWaitToken = null; let hubSettings = { ...DEFAULT_SETTINGS }; /** bindingKey -> { token, updatedAt } 一会话一线程 权威映射 */ let bindingRegistry = {}; /** planBindingKey (ws::title@ip#plan) -> { token, planLabel, cursorTitle, updatedAt } */ let planRegistry = {}; function normPlanLabel(label) { const t = String(label || '').trim(); return t || '(默认计划)'; } function normalizeExactToken(raw) { const t = String(raw || '').trim(); const m = t.match(/^(ct_[0-9a-f]{8})$/i); return m ? m[1].toLowerCase() : null; } function makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) { return `${makeBindingKey(workspace, cursorTitle, hostIp)}#${normPlanLabel(planLabel)}`; } function loadPlanRegistry() { try { planRegistry = JSON.parse(fs.readFileSync(PLAN_BINDINGS_FILE, 'utf8')); } catch { planRegistry = {}; } } function savePlanRegistry() { try { fs.writeFileSync(PLAN_BINDINGS_FILE, JSON.stringify(planRegistry, null, 2)); } catch (e) { log('plan-bindings save failed', String(e.message || e)); } } function registerPlanBinding(workspace, cursorTitle, hostIp, planLabel, token) { if (!cursorTitle || !token) return; const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel); planRegistry[pk] = { token, planLabel: normPlanLabel(planLabel), cursorTitle: normTitle(cursorTitle), workspace: normWorkspace(workspace), hostIp: normHostIp(hostIp), updatedAt: nowMs(), }; planRegistry[token] = { planBindingKey: pk, updatedAt: nowMs() }; savePlanRegistry(); const s = sessions.get(token); if (s) { s.planLabel = normPlanLabel(planLabel); s.planBindingKey = pk; } } function getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) { const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel); const hit = planRegistry[pk]; if (!hit || !hit.token) return null; const tok = normalizeExactToken(hit.token); if (!tok || !sessions.has(tok) || sessions.get(tok).status === 'completed') return null; return tok; } function getPlanByToken(token) { const tok = normalizeExactToken(token); if (!tok) return null; const rev = planRegistry[tok]; if (rev && rev.planBindingKey && planRegistry[rev.planBindingKey]) { return planRegistry[rev.planBindingKey]; } const s = sessions.get(tok); if (!s) return null; return { token: tok, planLabel: s.planLabel || normPlanLabel(s.title), cursorTitle: s.cursorTitle || s.title || '', workspace: s.workspace || '', }; } function loadBindingRegistry() { try { bindingRegistry = JSON.parse(fs.readFileSync(BINDINGS_FILE, 'utf8')); } catch { bindingRegistry = {}; } } function saveBindingRegistry() { try { fs.writeFileSync(BINDINGS_FILE, JSON.stringify(bindingRegistry, null, 2)); } catch (e) { log('bindings save failed', String(e.message || e)); } } function getHostIp() { const ifs = os.networkInterfaces(); for (const name of Object.keys(ifs)) { for (const iface of ifs[name] || []) { if (iface.family === 'IPv4' && !iface.internal) return iface.address; } } return '127.0.0.1'; } function hubEndpointLabel() { return `127.0.0.1:${HTTP_PORT}`; } function normHostIp(ip) { const t = String(ip || '').trim(); return t || getHostIp(); } function legacyBindingKey(workspace, cursorTitle) { return `${normWorkspace(workspace)}::${normTitle(cursorTitle)}`; } function makeBindingKey(workspace, cursorTitle, hostIp) { const ip = normHostIp(hostIp); return `${legacyBindingKey(workspace, cursorTitle)}@${ip}`; } function lookupRegistry(workspace, cursorTitle, hostIp) { if (!cursorTitle) return null; const key = makeBindingKey(workspace, cursorTitle, hostIp); const hit = bindingRegistry[key]; if (hit && hit.token) return { key, token: hit.token, hostIp: hit.hostIp || ipFromKey(key), hub: hit.hub || '' }; const leg = legacyBindingKey(workspace, cursorTitle); const old = bindingRegistry[leg]; if (old && old.token) return { key: leg, token: old.token, legacy: true, hostIp: old.hostIp || '', hub: old.hub || '' }; return null; } function ipFromKey(key) { const at = String(key || '').lastIndexOf('@'); return at >= 0 ? key.slice(at + 1) : ''; } function registerBinding(workspace, cursorTitle, token, meta = {}) { if (!cursorTitle || !token) return; const hostIp = normHostIp(meta.hostIp); const hub = meta.hub || hubEndpointLabel(); const key = makeBindingKey(workspace, cursorTitle, hostIp); bindingRegistry[key] = { token, hostIp, hub, updatedAt: nowMs() }; const leg = legacyBindingKey(workspace, cursorTitle); if (leg !== key && bindingRegistry[leg]) delete bindingRegistry[leg]; saveBindingRegistry(); const s = sessions.get(token); if (s) { s.bindingKey = key; s.cursorTitle = cursorTitle; s.workspace = workspace || s.workspace; s.hostIp = hostIp; s.hub = hub; s.bindingLockedAt = s.bindingLockedAt || nowMs(); s.bindingCheckCount = (s.bindingCheckCount || 0) + 1; const pl = meta.planLabel || s.planLabel || s.title; registerPlanBinding(workspace, cursorTitle, hostIp, pl, token); } } function getRegistryToken(workspace, cursorTitle, hostIp) { const hit = lookupRegistry(workspace, cursorTitle, hostIp); return hit ? hit.token : null; } function getTokenOwnerTitle(workspace, token) { if (!token) return null; const s = sessions.get(token); if (s?.cursorTitle) return normTitle(s.cursorTitle); const ws = normWorkspace(workspace); const prefix = `${ws}::`; for (const [key, hit] of Object.entries(bindingRegistry)) { if (hit && hit.token === token && key.startsWith(prefix)) { let rest = key.slice(prefix.length); const at = rest.lastIndexOf('@'); if (at >= 0) rest = rest.slice(0, at); return normTitle(rest); } } return null; } /** Cursor 标签改名:同 ct_ 迁移到新标题,删除旧 bindingKey */ function migrateBindingRename(workspace, token, newTitle, hostIp) { if (!token || !newTitle) return { ok: false }; const oldTitle = getTokenOwnerTitle(workspace, token); const newNorm = normTitle(newTitle); if (!oldTitle || oldTitle === newNorm) return { ok: false, renamed: false }; const ws = normWorkspace(workspace); const ip = normHostIp(hostIp); const prefix = `${ws}::`; for (const key of Object.keys(bindingRegistry)) { if (!key.startsWith(prefix)) continue; if (bindingRegistry[key]?.token === token) delete bindingRegistry[key]; } const s = sessions.get(token); const hub = (s && s.hub) || hubEndpointLabel(); registerBinding(workspace, newTitle, token, { hostIp: ip, hub }); if (s) { s.cursorTitle = newTitle; s.title = newTitle; s.bindingRenamedFrom = oldTitle; s.bindingRenamedAt = nowMs(); } saveBindingRegistry(); saveSessions(); log('binding rename', { token, from: oldTitle, to: newNorm, hostIp: ip }); return { ok: true, renamed: true, from: oldTitle, to: newNorm }; } function buildBindingContext(workspace, cursorTitle, hostIp, planLabel) { const ip = normHostIp(hostIp); const titleNorm = normTitle(cursorTitle); let regToken = null; if (planLabel) { regToken = getTokenByPlan(workspace, cursorTitle, ip, planLabel); } if (!regToken) { const hit = lookupRegistry(workspace, cursorTitle, ip); regToken = hit ? hit.token : null; } const wsNorm = normWorkspace(workspace); const active = []; for (const [token, s] of sessions.entries()) { if (s.status === 'completed') continue; if (normWorkspace(s.workspace) !== wsNorm) continue; const st = normTitle(s.cursorTitle || s.title || ''); if (titleNorm && titleNorm !== '(未命名标签)' && st !== titleNorm) continue; active.push({ token, title: s.title || s.cursorTitle || '', cursorTitle: s.cursorTitle || s.title || '', planLabel: s.planLabel || '', hostIp: s.hostIp || '', hub: s.hub || '', isActiveWait: token === activeWaitToken, status: pendingWaits.has(token) ? 'waiting' : (s.status || 'idle'), }); } const mode = regToken ? 'reuse' : (active.length > 0 ? 'choice' : 'auto_new'); return { mode, hostIp: ip, hub: hubEndpointLabel(), hubUrl: `http://${hubEndpointLabel()}/`, bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, ip) : '', planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, ip, planLabel) : '', boundToken: regToken, planLabel: planLabel ? normPlanLabel(planLabel) : '', activeSessions: active, }; } function validateTokenBinding(token, workspace, cursorTitle, hostIp, opts = {}) { if (!cursorTitle || normTitle(cursorTitle) === '(未命名标签)') { return { ok: true }; } const titleNorm = normTitle(cursorTitle); const ipNorm = normHostIp(hostIp); const key = makeBindingKey(workspace, cursorTitle, ipNorm); const s = sessions.get(token); if (!s) { return { ok: false, error: 'UNKNOWN_CONVERSATION_TOKEN', bindingKey: key, got: token, hint: `完整 token ${token} 不在 Hub 会话库/绑定库中;找不到时才允许新建计划。`, }; } if (s?.hostIp && normHostIp(s.hostIp) !== ipNorm) { return { ok: false, error: 'HOST_IP_MISMATCH', bindingKey: key, expectedHostIp: s.hostIp, gotHostIp: ipNorm, hint: `ct_${token.slice(3)} 绑定在 IP ${s.hostIp},当前连接 ${ipNorm},禁止跨机串线。`, }; } const ownerTitle = getTokenOwnerTitle(workspace, token); if (ownerTitle && ownerTitle !== titleNorm) { if (opts.allowTitleRename) { const mig = migrateBindingRename(workspace, token, cursorTitle, ipNorm); if (mig.renamed) { return { ok: true, titleRenamed: true, from: mig.from, to: mig.to }; } } return { ok: false, error: 'TOKEN_BOUND_TO_OTHER_TITLE', bindingKey: key, tokenOwnerTitle: ownerTitle, gotTitle: titleNorm, expected: getRegistryToken(workspace, cursorTitle, ipNorm) || '(未绑定,请 init)', got: token, hint: `ct_${token.slice(3)} 已绑定标签「${ownerTitle}」,禁止在「${cursorTitle}」使用。请 init_conversation(cursorTitle=当前标签名, newPlan:true)。`, }; } const regToken = getRegistryToken(workspace, cursorTitle, ipNorm); const plan = getPlanByToken(token); const planOwnsToken = !!( plan && plan.token === token && normWorkspace(plan.workspace) === normWorkspace(workspace) && normTitle(plan.cursorTitle) === titleNorm ); if (regToken && regToken !== token && !planOwnsToken) { return { ok: false, error: 'WRONG_TOKEN_FOR_BINDING', bindingKey: key, expected: regToken, got: token, hint: `标签「${cursorTitle}」已锁定 ${regToken},禁止串用 ${token};若这是旧计划,请带完整 token 且保持 planLabel 绑定。`, }; } if (s?.bindingKey && s.bindingKey !== key && s.bindingKey !== legacyBindingKey(workspace, cursorTitle)) { return { ok: false, error: 'TOKEN_BINDING_MISMATCH', expected: s.bindingKey, got: key, hint: `ct_${token.slice(3)} 属于其它标签,本标签应使用 ${regToken || '对应 ct_'}`, }; } if (s) { s.bindingCheckCount = (s.bindingCheckCount || 0) + 1; s.lastBindingCheckAt = nowMs(); } return { ok: true, bindingKey: key, hostIp: ipNorm, hub: hubEndpointLabel(), bindingUnchanged: true, bindingCheckCount: s?.bindingCheckCount || 0, }; } function normWorkspace(ws) { return String(ws || '').replace(/\/+$/, '') || '(unknown)'; } function normTitle(title) { const t = String(title || '').trim(); return t || '(未命名标签)'; } function findBoundSession(workspace, cursorTitle, hostIp, planLabel) { if (!cursorTitle) return null; if (planLabel) { const byPlan = getTokenByPlan(workspace, cursorTitle, hostIp, planLabel); if (byPlan) return byPlan; } const reg = getRegistryToken(workspace, cursorTitle, hostIp); if (reg) { const tok = normalizeExactToken(reg); if (tok && sessions.has(tok) && sessions.get(tok).status !== 'completed') return tok; } return null; } /** 仅标记 MCP 最近挂起的 ct_;不清其它会话的续跑/定时器(多标签并行保活) */ function setActiveWaitToken(token) { activeWaitToken = token; log('activeWaitToken', token); } function parseTitleFromBindingKey(key) { const at = String(key || '').lastIndexOf('@'); const left = at >= 0 ? key.slice(0, at) : key; const idx = left.indexOf('::'); return idx >= 0 ? normTitle(left.slice(idx + 2)) : normTitle(left); } function parseWorkspaceFromBindingKey(key) { const at = String(key || '').lastIndexOf('@'); const left = at >= 0 ? key.slice(0, at) : key; const idx = left.indexOf('::'); return idx >= 0 ? normWorkspace(left.slice(0, idx)) : '(unknown)'; } /** bindings.json 有、sessions 无 → 恢复占位会话,避免面板丢 ct_、乱绑 */ /** 从 thread-bindings/*.json 回填丢失的 bindings.json(防误删/多进程写丢) */ function rebuildBindingRegistryFromThreadFiles() { if (!fs.existsSync(THREAD_BINDINGS_DIR)) return 0; let n = 0; for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { if (!name.endsWith('.json')) continue; try { const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); const token = hit && hit.token; const key = hit && hit.bindingKey; if (!token || !key) continue; const cur = bindingRegistry[key]; if (cur && cur.token && cur.token !== token) continue; if (!cur || cur.token === token) { bindingRegistry[key] = { token, hostIp: hit.hostIp || ipFromKey(key) || getHostIp(), hub: hit.hub || hubEndpointLabel(), updatedAt: hit.updatedAt || nowMs(), }; n++; } } catch (e) { log('thread-binding read skip', name, String(e.message || e)); } } if (n > 0) { saveBindingRegistry(); log('rebuild bindings from thread files', { addedOrUpdated: n }); } return n; } function isVerticallyLockedToken(token) { if (!token || !fs.existsSync(THREAD_BINDINGS_DIR)) return false; try { for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { if (!name.endsWith('.json')) continue; const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); if (hit && hit.token === token && hit.verticalLock) return true; } } catch {} return false; } function restoreSessionsFromBindings() { rebuildBindingRegistryFromThreadFiles(); let n = 0; for (const [key, hit] of Object.entries(bindingRegistry)) { if (!hit || !hit.token) continue; if (sessions.has(hit.token)) continue; const title = parseTitleFromBindingKey(key); const workspace = parseWorkspaceFromBindingKey(key); const hostIp = hit.hostIp || ipFromKey(key) || getHostIp(); sessions.set(hit.token, { token: hit.token, title, planLabel: '', cursorTitle: title, bindingKey: key, hostIp, hub: hit.hub || hubEndpointLabel(), workspace, status: 'idle', phase: 'running', preRun: null, optimizeRun: null, sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode), timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false, timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null ? hubSettings.timedReplyIntervalMs : 0, bindingCheckCount: 0, bindingLockedAt: hit.updatedAt || nowMs(), createdAt: hit.updatedAt || nowMs(), lastActiveAt: hit.updatedAt || nowMs(), messages: [], restoredFromBinding: true, connectionHeld: false, status: 'idle', }); n++; log('restore session from binding', hit.token, key); } if (n > 0) saveSessions(); return n; } function registryTokenForSession(token, s) { if (!s) return token; const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp); const byKey = bindingRegistry[key]?.token; if (byKey) return byKey; return getRegistryToken(s.workspace, s.cursorTitle || s.title, s.hostIp) || token; } function bindingOkForSession(token, s) { if (!s) return true; const reg = registryTokenForSession(token, s); return reg === token; } function clearActiveWaitToken(token) { if (activeWaitToken === token) activeWaitToken = null; } function dedupeBindingSessions() { const groups = new Map(); for (const [token, s] of sessions.entries()) { if (s.status === 'completed') continue; if (pendingWaits.has(token)) continue; const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title); if (!groups.has(key)) groups.set(key, []); groups.get(key).push({ token, s }); } let removed = 0; for (const [, arr] of groups) { if (arr.length <= 1) continue; arr.sort((a, b) => (b.s.lastActiveAt || 0) - (a.s.lastActiveAt || 0)); for (let i = 1; i < arr.length; i++) { const { token, s } = arr[i]; if ((s.messages || []).length <= 2 && s.status === 'idle') { sessions.delete(token); removed++; } } } if (removed > 0) { saveSessions(); log('dedupe bindings', { removed }); } return removed; } function loadSettings() { try { hubSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) }; } catch { hubSettings = { ...DEFAULT_SETTINGS }; } if (Number(hubSettings.autoContinueDelayMs) === 400) { hubSettings.autoContinueDelayMs = 10000; saveSettings(); } } function saveSettings() { try { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(hubSettings, null, 2)); } catch (e) { log('settings save failed', String(e.message || e)); } } loadSettings(); function loadSessions() { try { const raw = fs.readFileSync(SESSIONS_FILE, 'utf8'); for (const [k, v] of Object.entries(JSON.parse(raw))) sessions.set(k, v); log('sessions loaded', { count: sessions.size }); } catch (e) { log('sessions load skip', String(e.message || e)); } } let lastMongoSyncAt = 0; const MONGO_SYNC_MIN_MS = 120000; function syncMongoAsync() { try { const now = nowMs(); if (now - lastMongoSyncAt < MONGO_SYNC_MIN_MS) return; lastMongoSyncAt = now; const syncScript = path.join(__dirname, 'mongo_sync.py'); if (!fs.existsSync(syncScript)) return; const { spawn } = require('child_process'); spawn('python3', [syncScript], { detached: true, stdio: 'ignore' }).unref(); } catch {} } /** 面板 /api/state 轮询勿做重 IO;维护任务降频 */ let lastStateMaintenanceAt = 0; const STATE_MAINTENANCE_MS = 60000; function maybeStateMaintenance() { const now = nowMs(); if (now - lastStateMaintenanceAt < STATE_MAINTENANCE_MS) return; lastStateMaintenanceAt = now; restoreSessionsFromBindings(); pruneSessions(); } function saveSessions() { try { const obj = {}; for (const [k, v] of sessions.entries()) obj[k] = v; fs.writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2)); syncMongoAsync(); } catch (e) { log('sessions save failed', String(e.message || e)); } } loadSessions(); loadBindingRegistry(); loadPlanRegistry(); rebuildBindingRegistryFromThreadFiles(); /** 从 sessions 回填 registry(迁移) */ for (const [token, s] of sessions.entries()) { if (s.status === 'completed') continue; const key = s.bindingKey || (s.cursorTitle ? makeBindingKey(s.workspace, s.cursorTitle) : ''); if (key && !bindingRegistry[key]) { bindingRegistry[key] = { token, updatedAt: s.lastActiveAt || s.createdAt || Date.now() }; } const pl = String(s.planLabel || s.title || '').trim(); const ct = s.cursorTitle || s.title || ''; if (pl && ct && !getPlanByToken(token)) { registerPlanBinding(s.workspace, ct, s.hostIp, pl, token); } } saveBindingRegistry(); /** 清理 sessions.json 里残留的 waiting(旧架构遗留),仅以内存 pendingWaits 为准 */ for (const [token, s] of sessions.entries()) { if (s.status === 'waiting') s.status = 'idle'; } const CONTINUE_MODES = new Set(['standard', 'turbo', 'optimize']); function normalizeContinueMode(mode) { const m = String(mode || '').trim(); return CONTINUE_MODES.has(m) ? m : 'standard'; } function getSessionContinueMode(s) { if (s && s.continueMode) return normalizeContinueMode(s.continueMode); return normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode || 'standard'); } function effectiveAutoContinue(s) { if (s && typeof s.sessionAutoContinue === 'boolean') return s.sessionAutoContinue; if (hubSettings.defaultSessionAutoContinue === false) return false; return hubSettings.autoContinue !== false; } function effectiveTimedReply(s) { if (hubSettings.timedReplyEnabled === false) return false; if (s && s.timedReplyEnabled === false) return false; return true; } function shouldAlwaysKeepAlive(s) { if (hubSettings.alwaysKeepAlive === false) return false; if (s && s.alwaysKeepAlive === false) return false; return effectiveAutoContinue(s); } function buildKeepAliveContinueText(s, review) { const mode = getSessionContinueMode(s); const goal = String((s && s.projectGoal) || (review && review.projectGoal) || '').slice(0, 150); if (review && review.isIdleNext) { return `【挂起】当前会话已保持连接,下一步状态是 wait / 待命。等待下一条消息即可,不要结束对话。`; } if (review && review.nextStep && !review.isIdleNext) { return `【保活续跑】继续执行▶下一步:${review.nextStep.slice(0, 400)}。保持连接勿停。`; } if (mode === 'turbo') { return String( hubSettings.keepAliveTurboText || hubSettings.turboContinueText || '【保活续跑】保持连接,继续执行当前计划与学习清单,直推直到目标完成。', ); } if (mode === 'optimize') { return '【保活续跑】保持连接,继续优化流程:总结→检索→补文档→开发,按学习清单推进。'; } if (goal) { return `【挂起】目标已记录:${goal}。当前状态维持 wait / 待命,等待下一条消息继续,不要结束对话。`; } return String( hubSettings.keepAliveText || hubSettings.timedReplyText || '【保活续跑】保持持久对话连接,继续执行与学习,勿停。', ); } function isTimedReplyAuto(s) { if (s && s.sessionTimedIntervalMs != null) { const sv = Number(s.sessionTimedIntervalMs); if (Number.isFinite(sv) && sv <= 0) return true; if (Number.isFinite(sv) && sv >= TIMED_REPLY_MIN_MS) return false; } const raw = s && s.timedReplyIntervalMs != null ? Number(s.timedReplyIntervalMs) : Number(hubSettings.timedReplyIntervalMs); return !Number.isFinite(raw) || raw <= 0; } function getTimedReplyIntervalMs(s) { if (isTimedReplyAuto(s)) return 0; const raw = s.sessionTimedIntervalMs != null ? Number(s.sessionTimedIntervalMs) : (s && s.timedReplyIntervalMs != null ? Number(s.timedReplyIntervalMs) : Number(hubSettings.timedReplyIntervalMs)); if (Number.isFinite(raw) && raw >= TIMED_REPLY_MIN_MS) return Math.floor(raw); return 0; } /** 「自动」间隔 = 10 秒保活连接 */ function getEffectiveTimedIntervalMs(s) { const raw = getTimedReplyIntervalMs(s); if (raw >= TIMED_REPLY_MIN_MS) return raw; return CONNECTION_KEEPALIVE_MS; } function markConnectionHeld(s) { if (!s || s.status === 'completed' || s.connectionEnded) return; s.connectionHeld = true; s.lastKeepAliveAt = nowMs(); if (s.status !== 'waiting') s.status = 'holding'; } function clearConnectionHeld(s) { if (!s) return; s.connectionHeld = false; if (s.status === 'holding') s.status = 'idle'; } function isSessionConnectionHeld(s, pending) { if (!s || s.status === 'completed' || s.connectionEnded) return false; return !!(pending || s.connectionHeld); } function pruneSteerFromMessages(s, texts) { if (!s?.messages?.length || !texts?.length) return; const set = new Set(texts.map((t) => String(t || '').trim()).filter(Boolean)); s.messages = s.messages.filter((m) => { if (!m.queued || m.role !== 'user') return true; return !set.has(String(m.content || '').trim()); }); } function clearWaitTimers(wait) { if (!wait) return; if (wait.autoTimer) { clearTimeout(wait.autoTimer); wait.autoTimer = null; } if (wait.timedReplyTimer) { clearTimeout(wait.timedReplyTimer); wait.timedReplyTimer = null; } wait.timedReplyFireAt = null; } function buildTimedReplyText(token, wait, s, review) { const mode = getSessionContinueMode(s); if (mode === 'turbo') { return String( hubSettings.timedReplyTurboText || hubSettings.turboContinueText || '【定时续跑】保持连接,继续执行当前计划,直推开发直到目标完成。', ); } if (mode === 'optimize') { return '【定时续跑】保持连接,继续优化流程:总结→检索→补文档→开发。'; } if (review.nextStep && !review.isIdleNext) { return `【定时回复】继续执行▶下一步:${review.nextStep.slice(0, 300)}`; } return String(hubSettings.timedReplyText || '【定时回复】保持持久对话连接,若仍有任务请继续执行。'); } function triggerTimedReply(token) { const wait = pendingWaits.get(token); if (!wait) return { ok: false, error: 'no pending wait' }; focusPanelTarget(token); const s = sessions.get(token); if (!effectiveTimedReply(s)) return { ok: false, error: 'timed reply disabled' }; const review = parseKaruoReview(wait.message || ''); const text = buildTimedReplyText(token, wait, s, review); if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer); wait.timedReplyTimer = null; wait.timedReplyFireAt = null; deliverReply(token, text, { auto: true, timed: true }); log('timedReply fired', token, { text: text.slice(0, 80) }); return { ok: true, token, text: text.slice(0, 120) }; } function scheduleTimedReply(token) { const s = sessions.get(token); if (!effectiveTimedReply(s)) return; const wait = pendingWaits.get(token); if (!wait) return; if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer); const interval = getEffectiveTimedIntervalMs(s); if (interval < TIMED_REPLY_MIN_MS) return; wait.timedReplyFireAt = nowMs() + interval; wait.timedReplyTimer = setTimeout(() => { if (!pendingWaits.has(token)) return; triggerTimedReply(token); }, interval); log('timedReply scheduled', token, { intervalMs: interval, fireAt: wait.timedReplyFireAt }); } /** 迁移:旧会话补默认单会话配置 */ for (const [, s] of sessions.entries()) { if (s.continueMode == null) { s.continueMode = normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode); } if (typeof s.sessionAutoContinue !== 'boolean') { s.sessionAutoContinue = hubSettings.defaultSessionAutoContinue !== false; } if (typeof s.timedReplyEnabled !== 'boolean') { s.timedReplyEnabled = hubSettings.defaultTimedReplyEnabled !== false; } if (s.timedReplyIntervalMs == null) { s.timedReplyIntervalMs = hubSettings.timedReplyIntervalMs != null ? hubSettings.timedReplyIntervalMs : CONNECTION_KEEPALIVE_MS; } if (s.sessionTimedIntervalMs == null && s.timedReplyIntervalMs == null) { s.sessionTimedIntervalMs = 0; } if (typeof s.connectionHeld !== 'boolean') s.connectionHeld = false; if (s.archived) continue; if (typeof s.sessionAutoContinue !== 'boolean' || s.sessionAutoContinue === false) { s.sessionAutoContinue = true; } if (s.timedReplyEnabled === false) s.timedReplyEnabled = true; if (s.alwaysKeepAlive !== false) s.alwaysKeepAlive = true; if (!s.lastKeepAliveAt) s.lastKeepAliveAt = s.lastActiveAt || s.createdAt || nowMs(); } saveSessions(); function archiveStaleSessions() { const now = nowMs(); const stale = []; for (const [token, s] of sessions.entries()) { if (s.archived || s.status === 'completed') continue; if (pendingWaits.has(token)) continue; const last = s.lastKeepAliveAt || s.lastActiveAt || s.createdAt || now; if (now - last < STALE_KEEPALIVE_MS) continue; stale.push({ token, title: s.title, cursorTitle: s.cursorTitle, planLabel: s.planLabel, workspace: s.workspace, lastKeepAliveAt: s.lastKeepAliveAt, lastActiveAt: s.lastActiveAt, msgCount: (s.messages || []).length, messages: (s.messages || []).slice(-50), }); s.archived = true; s.status = 'completed'; s.sessionAutoContinue = false; s.timedReplyEnabled = false; s.connectionHeld = false; s.archivedAt = now; s.archiveReason = 'stale_keepalive_2h'; } if (!stale.length) return 0; const day = new Date().toISOString().slice(0, 10); const dir = path.join(ARCHIVE_DIR, day); try { fs.mkdirSync(dir, { recursive: true }); } catch {} const file = path.join(dir, `stale_${now}.json`); try { fs.writeFileSync(file, JSON.stringify({ archivedAt: now, reason: 'no_keepalive_2h', sessions: stale }, null, 2)); } catch (e) { log('archive write failed', String(e.message || e)); } saveSessions(); log('archiveStaleSessions', { count: stale.length, file }); return stale.length; } function isSilentMessage(message, token) { const s = token ? sessions.get(token) : null; if (effectiveAutoContinue(s)) return true; const msg = String(message || '').replace(/```text|```/g, '').trim(); if (!msg) return true; if (/自动续跑空转|续跑机制正常|机制已就绪|空转中|Hub.*自动|仍在.*续跑/.test(msg)) return true; return false; } function stripTransportFence(message) { let msg = String(message || ''); const fenced = msg.match(/^```text\n([\s\S]*)\n```\s*$/); if (fenced) msg = fenced[1]; return msg.replace(/```text|```/g, '').trim(); } /** 从 Agent 回复解析卡若复盘 v5(🎯📌💡📝▶) */ function parseKaruoReview(message) { const raw = stripTransportFence(message); const hasReview = /\[卡若复盘\]|🎯\s*目标/.test(raw); let completionPct = null; const goalSection = raw.match(/🎯[\s\S]*?(?=\n\*\*📌|\n\*\*💡|\n\*\*📝|\n\*\*▶|$)/); const pctMatch = goalSection && goalSection[0].match(/([0-9]{1,3})\s*%/); if (pctMatch) completionPct = Math.min(100, Math.max(0, parseInt(pctMatch[1], 10))); let nextStep = ''; const nextBlock = raw.match(/\*\*▶\s*下一步执行\*\*\s*\n([\s\S]*?)(?=\n\*\*[📌💡📝🎯▶]|$)/); if (nextBlock) nextStep = nextBlock[1].trim(); if (!nextStep) { const alt = raw.match(/▶\s*下一步执行[^\n]*\n([\s\S]{1,400}?)(?=\n\n\*\*|$)/); if (alt) nextStep = alt[1].trim(); } nextStep = nextStep.replace(/^[\d]+[.、]\s*/, '').trim(); const isIdleNext = !nextStep || /^(无[。.]?|暂无|待命|等下一|等你的|等用户|无新任务|无待办|在\.|在$|\.\.\.|…)/.test(nextStep); const needsZhuiwen = completionPct != null && completionPct >= 100; let projectGoal = ''; const goalLine = raw.match(/🎯[^\n]+/); if (goalLine) { projectGoal = goalLine[0] .replace(/^\*\*🎯\s*目标·结果·达成率\*\*\s*/, '') .replace(/^🎯\s*目标·结果·达成率\s*/, '') .trim(); } return { hasReview, completionPct, nextStep, isIdleNext, needsZhuiwen, projectGoal, raw }; } function isIdleAssistantWait(message) { if (isSilentMessage(message)) return true; const review = parseKaruoReview(message); if (review.hasReview && review.isIdleNext && !review.needsZhuiwen) return true; const msg = stripTransportFence(message); if (msg.length < 50) return true; if (/^(\[RENEWAL\]|继续|__TIMEOUT_RENEW__)/.test(msg)) return true; return false; } function initPreRun(session, nextStep) { if (!nextStep) return; const prev = session.preRun; if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return; session.preRun = { nextStep, stage: 'zhuiwen', zhuiwenSent: false, searchSent: false, }; } function initOptimizeRun(session, nextStep) { if (!nextStep) return; const prev = session.optimizeRun; if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return; session.optimizeRun = { nextStep, stage: 'summarize' }; session.preRun = null; } function advanceOptimizeRunStage(session) { const opt = session.optimizeRun; if (!opt) return; const order = ['summarize', 'search', 'doc', 'execute', 'done']; const i = order.indexOf(opt.stage); if (i >= 0 && i < order.length - 1) opt.stage = order[i + 1]; } /** 优化续跑:总结 → 检索 → 补文档 → 执行 */ function resolveOptimizeContinueText(session, review) { const opt = session.optimizeRun; if (!opt?.nextStep || review.isIdleNext) return null; const step = opt.nextStep.slice(0, 300); const texts = { summarize: `【优化续跑①总结】回顾本会话聊天与已改代码,提炼/优化任务目标、缺口与风险,写入📌过程。`, search: `【优化续跑②检索】针对「${step}」:WebSearch 全网最佳实践 + GitHub 开源方案(各≥1次),对比选配套方案,摘要写📌。`, doc: `【优化续跑③补文档】把总结与检索结论补充进项目 开发文档/ 对应 MD(列出路径,不删旧内容)。`, execute: `【优化续跑④执行】文档已对齐,按文档继续执行▶下一步:${step}`, }; if (opt.stage === 'done') return null; const text = texts[opt.stage]; if (!text) return null; session.phase = `opt_${opt.stage}`; return text; } /** 续跑三段式:追问 → 全网/GitHub 检索 → 执行 ▶(挂起后自动续跑前必经) */ function resolvePreRunContinueText(session, review) { const pre = session.preRun; if (!pre || !pre.nextStep || review.isIdleNext) return null; const step = pre.nextStep.slice(0, 300); const ZHUIWEN_SKILL = '04_卡火(火)/火眼_智能追问/智能追问/SKILL.md'; if (pre.stage === 'zhuiwen') { if (pre.zhuiwenSent) { log('preRun pause await 追问完成', pre.stage); return null; } pre.zhuiwenSent = true; pre.stage = 'zhuiwen_wait'; session.phase = 'pre_zhuiwen'; return ( `【续跑①追问】挂起后续跑前,先读 \`${ZHUIWEN_SKILL}\`,对▶下一步「${step}」做 Human 3.0 结构化追问(目标/边界/验收,2~3 轮)。` + `在本轮正文输出追问清单与结论,复盘后 wait。你或面板确认后只回复「追问完成」。` ); } if (pre.stage === 'zhuiwen_wait') { log('preRun pause zhuiwen_wait'); return null; } if (pre.stage === 'search') { if (pre.searchSent) { log('preRun pause await 检索完成'); return null; } pre.searchSent = true; pre.stage = 'search_wait'; session.phase = 'pre_search'; return ( `【续跑②检索】追问已完成。针对「${step}」:` + `① WebSearch 全网查最佳实践/踩坑 ② GitHub 搜开源实现/Discussion/Issue(至少各 1 次有效检索)。` + `摘要写入📌过程,结论影响执行方案。完成后只回复「检索完成」。` ); } if (pre.stage === 'search_wait') { log('preRun pause search_wait'); return null; } if (pre.stage === 'execute') { session.preRun = { ...pre, stage: 'done' }; session.phase = 'running'; return `【续跑③执行】追问与检索已完成。现在执行▶下一步:${step}`; } return null; } function advancePreRunOnReply(session, reply) { if (!session.preRun) return; const r = String(reply || ''); if (/追问完成|验收通过|追问结束|验收完毕/.test(r)) { if (session.preRun.stage === 'zhuiwen_wait') { session.preRun.stage = 'search'; session.preRun.searchSent = false; session.phase = 'pre_search'; log('preRun advanced → search'); } } if (/检索完成|搜索完成|检索完毕|搜完/.test(r)) { if (session.preRun.stage === 'search_wait') { session.preRun.stage = 'execute'; session.phase = 'pre_execute'; log('preRun advanced → execute'); } } } function autoContinueMaxStreakLimit() { const n = Number(hubSettings.autoContinueMaxStreak); if (!Number.isFinite(n) || n <= 0) return 0; return Math.floor(n); } function deleteSession(token, force = false, opts = {}) { if (!sessions.has(token)) return { ok: false, error: 'not found' }; if (isVerticallyLockedToken(token) && !opts.unbind) { return { ok: false, error: 'BINDING_PROTECTED', hint: '该 ct_ 为垂直绑定会话,禁止面板/脚本误删;仅可删会话占位,绑定保留。如需解除请传 unbind:true。', }; } if (pendingWaits.has(token)) { if (!force) return { ok: false, error: 'waiting', hint: '会话挂起中,可强制删除' }; const wait = pendingWaits.get(token); if (wait) clearWaitTimers(wait); for (const r of wait.pollers) { try { json(r, 200, { status: 'gone', token }); } catch {} } pendingWaits.delete(token); } sessions.delete(token); saveSessions(); log('session deleted', token, { force }); return { ok: true, token }; } function pruneSessions() { const protectedTokens = new Set(pendingWaits.keys()); for (const hit of Object.values(bindingRegistry)) { if (hit && hit.token) protectedTokens.add(hit.token); } const max = Math.max(5, Number(hubSettings.maxSessions) || 30); const maxAgeMs = Math.max(1, Number(hubSettings.maxIdleAgeDays) || 7) * 86400000; const now = nowMs(); const entries = [...sessions.entries()].filter(([t]) => !protectedTokens.has(t)); entries.sort((a, b) => (b[1].lastActiveAt || 0) - (a[1].lastActiveAt || 0)); let pruned = 0; for (let i = max; i < entries.length; i++) { sessions.delete(entries[i][0]); pruned++; } for (const [token, s] of [...sessions.entries()]) { if (protectedTokens.has(token)) continue; const age = now - (s.lastActiveAt || s.createdAt || now); if (age > maxAgeMs && (s.status === 'idle' || s.status === 'completed')) { sessions.delete(token); pruned++; } } if (pruned > 0) { saveSessions(); log('pruned sessions', { count: pruned, remain: sessions.size }); } return pruned; } /** 删除隔离测试/空 holding/无消息 orphan;保留 registry+plan 绑定及有实质消息的会话 */ function cleanupUnusedPlans(opts = {}) { const keepExact = new Set((opts.keepTokens || []).map((t) => normalizeExactToken(t)).filter(Boolean)); for (const hit of Object.values(bindingRegistry)) { if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token)); } for (const [k, hit] of Object.entries(planRegistry)) { if (k.startsWith('ct_') && hit && hit.planBindingKey) continue; if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token)); } if (activeWaitToken) keepExact.add(normalizeExactToken(activeWaitToken)); for (const t of pendingWaits.keys()) keepExact.add(normalizeExactToken(t)); const deletedTokens = []; for (const [token, s] of [...sessions.entries()]) { const tok = normalizeExactToken(token); if (!tok) { sessions.delete(token); deletedTokens.push(token); continue; } if (keepExact.has(tok)) continue; const title = String(s.cursorTitle || s.title || ''); const msgs = (s.messages || []).length; const isTest = /隔离测试|isolation.?test/i.test(title); const isEmptyHold = (s.status === 'holding' || s.status === 'idle') && msgs === 0; const isCompleted = s.status === 'completed'; const isOrphan = msgs === 0 && !s.bindingKey && !s.planBindingKey; if (isTest || isCompleted || isEmptyHold || isOrphan) { if (pendingWaits.has(token)) { const wait = pendingWaits.get(token); if (wait.autoTimer) clearTimeout(wait.autoTimer); pendingWaits.delete(token); } if (activeWaitToken === token) clearActiveWaitToken(token); sessions.delete(token); deletedTokens.push(token); } else if (msgs >= 3) { keepExact.add(tok); } } for (const token of deletedTokens) { for (const [key, hit] of Object.entries(bindingRegistry)) { if (hit && hit.token === token) delete bindingRegistry[key]; } if (planRegistry[token]) delete planRegistry[token]; for (const [pk, hit] of Object.entries(planRegistry)) { if (hit && hit.token === token) delete planRegistry[pk]; } try { const tb = path.join(THREAD_BINDINGS_DIR, `${token}.json`); if (fs.existsSync(tb)) fs.unlinkSync(tb); } catch {} } saveSessions(); saveBindingRegistry(); savePlanRegistry(); log('cleanupUnusedPlans', { deleted: deletedTokens.length, remain: sessions.size }); return { deleted: deletedTokens.length, deletedTokens, remain: sessions.size }; } restoreSessionsFromBindings(); pruneSessions(); dedupeBindingSessions(); saveSessions(); function newToken() { return 'ct_' + crypto.randomBytes(4).toString('hex'); } function nowMs() { return Date.now(); } function readBody(req, max = 1024 * 1024) { return new Promise((resolve, reject) => { let body = ''; req.on('data', (c) => { body += c; if (body.length > max) { req.destroy(); reject(new Error('body too large')); } }); req.on('end', () => resolve(body)); req.on('error', reject); }); } function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); } function buildStateList() { const list = []; for (const [token, s] of sessions.entries()) { const pending = pendingWaits.get(token); const hideWaiting = effectiveAutoContinue(s); const needsUser = pending && !pending.silent && !hideWaiting; const connectionHeld = isSessionConnectionHeld(s, pending); list.push({ token, title: s.title || '(未命名)', displayTitle: s.displayTitle || '', status: needsUser ? 'waiting' : (pending || connectionHeld ? 'holding' : (s.status === 'completed' ? 'completed' : 'idle')), workspace: s.workspace, createdAt: s.createdAt, lastActiveAt: s.lastActiveAt, msgCount: (s.messages || []).length, pendingMessage: pending ? pending.message : null, pendingPrompt: pending ? pending.prompt : null, pendingTitle: pending ? pending.title : null, pendingSince: pending ? pending.createdAt : null, silent: pending ? !!pending.silent : false, autoRunning: !!(pending && hideWaiting), hasPending: !!pending, agentWaitActive: !!(pending && !pending.silent), waitingForUser: !!needsUser, connectionHeld: !!connectionHeld, isHung: !!(pending || connectionHeld), lastKeepAliveAt: s.lastKeepAliveAt || null, autoStreak: s.autoStreak || 0, cursorTitle: s.cursorTitle || s.title || '', bindingKey: s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp), hostIp: s.hostIp || '', hub: s.hub || hubEndpointLabel(), bindingCheckCount: s.bindingCheckCount || 0, isActiveWait: token === activeWaitToken, projectGoal: s.projectGoal || '', completionPct: typeof s.completionPct === 'number' ? s.completionPct : null, lastReview: s.lastReview || null, phase: s.phase || 'running', planLabel: s.planLabel || '', preRun: s.preRun || null, optimizeRun: s.optimizeRun || null, continueMode: getSessionContinueMode(s), sessionAutoContinue: effectiveAutoContinue(s), continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)], timedReplyEnabled: effectiveTimedReply(s), sessionTimedReply: effectiveTimedReply(s), timedReplyAuto: isTimedReplyAuto(s), timedReplyIntervalMs: getTimedReplyIntervalMs(s), sessionTimedIntervalMs: isTimedReplyAuto(s) ? 0 : getTimedReplyIntervalMs(s), effectiveTimedIntervalMs: getEffectiveTimedIntervalMs(s), timedReplyFireAt: pending && pending.timedReplyFireAt ? pending.timedReplyFireAt : null, registryToken: registryTokenForSession(token, s), bindingOk: bindingOkForSession(token, s), restoredFromBinding: !!s.restoredFromBinding, steerQueueCount: Array.isArray(s.steerQueue) ? s.steerQueue.length : 0, steerQueuePreview: (s.steerQueue || []).map((item, index) => ({ index, text: String(item.text || '').slice(0, 240), at: item.at || null, })), recentMessages: (s.messages || []).slice(-8).map((m) => ({ role: m.role, preview: String(m.content || '').replace(/```text|```/g, '').slice(0, 400), at: m.at, auto: !!m.auto, timed: !!m.timed, })), }); } list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); return list; } function queueSteerReply(token, reply) { const s = sessions.get(token); if (!s) return { ok: false, error: 'unknown token' }; if (!Array.isArray(s.steerQueue)) s.steerQueue = []; const text = String(reply || '').trim(); if (!text) return { ok: false, error: 'empty reply' }; s.steerQueue.push({ text, at: nowMs() }); (s.messages = s.messages || []).push({ role: 'user', content: text, at: nowMs(), queued: true }); s.lastActiveAt = nowMs(); saveSessions(); log('steer queued', token, { queueLen: s.steerQueue.length }); return { ok: true, queued: true, queueLen: s.steerQueue.length }; } function takeSteerReply(token) { const s = sessions.get(token); if (!s?.steerQueue?.length) return null; const items = s.steerQueue.splice(0); saveSessions(); const combined = items.map((i) => i.text).join('\n\n'); log('steer dequeue', token, { count: items.length, len: combined.length }); return combined || null; } function deliverReply(token, reply, meta = {}) { const wait = pendingWaits.get(token); if (!wait) return false; clearWaitTimers(wait); pendingWaits.delete(token); const s = sessions.get(token); if (s) { s.lastActiveAt = nowMs(); if (/结束持久对话/.test(String(reply || ''))) { clearConnectionHeld(s); s.connectionEnded = true; s.sessionAutoContinue = false; s.status = 'idle'; } else { // 回复送达后仍保持计划级连接态;下一次 Agent wait 会继续接收排队消息。 markConnectionHeld(s); } (s.messages = s.messages || []).push({ role: 'user', content: reply, at: nowMs(), auto: !!meta.auto, timed: !!meta.timed, }); advancePreRunOnReply(s, reply); if (/追问完成|验收通过|追问结束|验收完毕/.test(reply) && !s.preRun) { s.phase = 'zhuiwen_done'; } if (meta.auto || meta.timed) { s.lastKeepAliveAt = nowMs(); } if (meta.auto) { s.autoStreak = (s.autoStreak || 0) + 1; if (getSessionContinueMode(s) === 'optimize' && s.optimizeRun && s.optimizeRun.stage !== 'done') { advanceOptimizeRunStage(s); } } else { s.autoStreak = 0; } saveSessions(); } for (const res of wait.pollers) { try { json(res, 200, { status: 'replied', reply, token }); } catch {} } wait.pollers.clear(); if (typeof wait.resolve === 'function') wait.resolve(reply); clearActiveWaitToken(token); log('reply delivered', token, `len=${reply.length}`, meta.timed ? 'timed' : (meta.auto ? 'auto' : 'manual')); return true; } function isTurboMode(s) { return getSessionContinueMode(s) === 'turbo'; } function isOptimizeMode(s) { return getSessionContinueMode(s) === 'optimize'; } /** 构建自动/手动「继续」文案;返回 null 表示本轮不续跑 */ function buildAutoContinueText(token, wait, s, review) { const sGoal = s && s.projectGoal ? String(s.projectGoal) : review.projectGoal || ''; const sPct = review.completionPct != null ? review.completionPct : (s && typeof s.completionPct === 'number' ? s.completionPct : null); let text = String(hubSettings.autoContinueText || '继续'); if (isOptimizeMode(s)) { if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) { log('optimizeContinue stop complete', token, { sPct }); return null; } if (!s.optimizeRun) { const seed = (review.nextStep && !review.isIdleNext) ? review.nextStep : (sGoal || '继续推进当前任务'); initOptimizeRun(s, seed); } const optText = resolveOptimizeContinueText(s, review); if (optText) { saveSessions(); return optText; } if (review.nextStep && !review.isIdleNext) { return `【优化续跑·执行】继续▶:${review.nextStep.slice(0, 400)}。按已补文档直推。`; } if (sGoal) { const pctPart = sPct != null ? `(约 ${sPct}%)` : ''; return `【优化续跑】继续推进:${sGoal.slice(0, 150)}${pctPart}。`; } return null; } if (isTurboMode(s)) { if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) { log('turboContinue stop complete', token, { sPct }); return null; } if (review.nextStep && !review.isIdleNext) { return ( `【直通续跑】继续执行▶下一步:${review.nextStep.slice(0, 400)}。` + '直推开发不要停,补缺失步骤即可,跳过追问/检索等待。' ); } if (sGoal) { const pctPart = sPct != null ? `(当前约 ${sPct}%)` : ''; if (sPct != null && sPct >= 100 && review.isIdleNext) return null; return ( `【直通续跑】继续推进:${sGoal.slice(0, 150)}${pctPart}。` + '一直开发直到目标完成,勿停勿等复盘确认。' ); } if (!review.hasReview) { return String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划,直推开发直到完成。'); } return text; } if (!review.hasReview) { log('autoContinue skipped no karuo review', token); return null; } if (review.isIdleNext && !review.needsZhuiwen) { log('autoContinue skipped idle next step', token, { nextStep: review.nextStep }); return null; } const preText = s ? resolvePreRunContinueText(s, review) : null; if (preText) { saveSessions(); return preText; } if (s && s.preRun && !review.isIdleNext) { log('autoContinue paused preRun pipeline', token, { stage: s.preRun.stage }); return null; } if (review.needsZhuiwen && s && s.phase !== 'zhuiwen_done') { if (!review.isIdleNext) initPreRun(s, review.nextStep); const retry = s ? resolvePreRunContinueText(s, review) : null; if (retry) { saveSessions(); return retry; } return null; } if (review.nextStep && !review.isIdleNext) { if (s) initPreRun(s, review.nextStep); const retry = s ? resolvePreRunContinueText(s, review) : null; if (retry) { saveSessions(); return retry; } return null; } if (sGoal) { const pctPart = sPct != null ? `(当前完成度约 ${sPct}%)` : ''; if (sPct != null && sPct >= 100 && s && s.phase === 'zhuiwen_done') { log('autoContinue skipped after zhuiwen', token); return null; } return `继续推进:${sGoal.slice(0, 120)}${pctPart}。按▶下一步执行,勿重复已完成步骤。`; } return text; } function focusPanelTarget(token) { if (!pendingWaits.has(token)) return false; if (activeWaitToken !== token) { log('panel focus activeWaitToken', { from: activeWaitToken, to: token }); setActiveWaitToken(token); } return true; } function triggerContinue(token, meta = {}) { const wait = pendingWaits.get(token); if (!wait) { return { ok: false, error: 'no pending wait', hint: '该 ct_ 未挂起,请在对应 Cursor 标签等 Agent wait 后再发' }; } focusPanelTarget(token); const s = sessions.get(token); const review = parseKaruoReview(wait.message || ''); let text = buildAutoContinueText(token, wait, s, review); if (!text && shouldAlwaysKeepAlive(s)) { text = buildKeepAliveContinueText(s, review); } if (!text) { const mode = getSessionContinueMode(s); text = mode === 'turbo' ? String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划,直推开发直到完成。') : mode === 'optimize' ? '【优化续跑】继续执行当前计划:总结→检索→补文档→开发。' : String(hubSettings.autoContinueText || '继续'); } if ((isTurboMode(s) || isOptimizeMode(s)) && s && s.preRun) { s.preRun = null; s.phase = 'running'; saveSessions(); } if (wait.autoTimer) clearTimeout(wait.autoTimer); deliverReply(token, text, { auto: !!meta.auto, manualContinue: !!meta.manualContinue }); return { ok: true, token, text: text.slice(0, 120) }; } function scheduleAutoContinue(token) { const s0 = sessions.get(token); if (!effectiveAutoContinue(s0)) return; const wait = pendingWaits.get(token); if (!wait) return; const s = sessions.get(token); const maxStreak = autoContinueMaxStreakLimit(); if (maxStreak > 0 && s && (s.autoStreak || 0) >= maxStreak) { log('autoContinue paused', token, { streak: s.autoStreak, maxStreak }); return; } if (wait.autoTimer) clearTimeout(wait.autoTimer); const delay = Math.max(1000, Number(hubSettings.autoContinueDelayMs) || 10000); const review = parseKaruoReview(wait.message || ''); let text = buildAutoContinueText(token, wait, s, review); if (!text && shouldAlwaysKeepAlive(s)) { text = buildKeepAliveContinueText(s, review); log('autoContinue keepalive fallback', token, { mode: getSessionContinueMode(s) }); } if (!text) return; wait.autoTimer = setTimeout(() => { if (!pendingWaits.has(token)) return; triggerContinue(token, { auto: true }); }, delay); } function readPanelVersion() { try { const m = fs.readFileSync(PANEL_HTML, 'utf8').match(/PANEL_VERSION\s*=\s*['"]([^'"]+)['"]/); if (m) return m[1]; } catch {} return 'unknown'; } const server = http.createServer(async (req, res) => { try { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); } const u = new URL(req.url, `http://${req.headers.host}`); if (u.pathname === '/api/health') { return json(res, 200, { ok: true, pid: process.pid, waiting: pendingWaits.size, activeWaitToken, panelVersion: readPanelVersion(), }); } if (u.pathname === '/' || u.pathname === '/panel') { let html = fs.readFileSync(PANEL_HTML, 'utf8'); const boot = JSON.stringify({ sessions: buildStateList(), settings: hubSettings, panelVersion: readPanelVersion(), activeWaitToken, }).replace(/', `\n`); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', }); return res.end(html); } if (u.pathname === '/api/state' && req.method === 'GET') { maybeStateMaintenance(); for (const [, s] of sessions.entries()) markConnectionHeld(s); const list = buildStateList(); const primary = (activeWaitToken && pendingWaits.has(activeWaitToken)) ? activeWaitToken : (list.find((s) => s.hasPending)?.token || list.find((s) => s.status === 'waiting')?.token || null); return json(res, 200, { sessions: list, primaryWaitingToken: primary, activeWaitToken, hubPid: process.pid, settings: hubSettings, sessionCount: list.length, }); } if (u.pathname === '/api/settings' && req.method === 'GET') { return json(res, 200, { ok: true, settings: hubSettings }); } if (u.pathname === '/api/archive' && req.method === 'GET') { const files = []; try { if (fs.existsSync(ARCHIVE_DIR)) { for (const day of fs.readdirSync(ARCHIVE_DIR)) { const dayDir = path.join(ARCHIVE_DIR, day); if (!fs.statSync(dayDir).isDirectory()) continue; for (const f of fs.readdirSync(dayDir)) { if (!f.endsWith('.json')) continue; const fp = path.join(dayDir, f); let meta = { file: fp, day }; try { const j = JSON.parse(fs.readFileSync(fp, 'utf8')); meta = { ...meta, archivedAt: j.archivedAt, reason: j.reason, count: (j.sessions || []).length }; } catch {} files.push(meta); } } } } catch (e) { log('archive list err', String(e.message || e)); } files.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0)); return json(res, 200, { ok: true, archiveDir: ARCHIVE_DIR, staleMs: STALE_KEEPALIVE_MS, files }); } if (u.pathname === '/api/settings' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); hubSettings = { ...hubSettings, ...(typeof body.autoContinue === 'boolean' ? { autoContinue: body.autoContinue } : {}), ...(CONTINUE_MODES.has(body.autoContinueMode) ? { autoContinueMode: body.autoContinueMode, defaultContinueMode: body.autoContinueMode } : {}), ...(typeof body.defaultSessionAutoContinue === 'boolean' ? { defaultSessionAutoContinue: body.defaultSessionAutoContinue } : {}), ...(body.autoContinueDelayMs != null ? { autoContinueDelayMs: Number(body.autoContinueDelayMs) } : {}), ...(body.autoContinueText != null ? { autoContinueText: String(body.autoContinueText) } : {}), ...(body.turboContinueText != null ? { turboContinueText: String(body.turboContinueText) } : {}), ...(body.autoContinueMaxStreak != null ? { autoContinueMaxStreak: Number(body.autoContinueMaxStreak) } : {}), ...(typeof body.timedReplyEnabled === 'boolean' ? { timedReplyEnabled: body.timedReplyEnabled } : {}), ...(typeof body.defaultTimedReplyEnabled === 'boolean' ? { defaultTimedReplyEnabled: body.defaultTimedReplyEnabled } : {}), ...(body.timedReplyIntervalMs != null ? { timedReplyIntervalMs: Number(body.timedReplyIntervalMs) } : {}), ...(body.timedReplyText != null ? { timedReplyText: String(body.timedReplyText) } : {}), ...(body.timedReplyTurboText != null ? { timedReplyTurboText: String(body.timedReplyTurboText) } : {}), }; saveSettings(); log('settings updated', hubSettings); return json(res, 200, { ok: true, settings: hubSettings }); } if (u.pathname === '/api/session/config' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token } = body; if (!token || !sessions.has(token)) { return json(res, 404, { ok: false, error: 'not found' }); } const s = sessions.get(token); if (typeof body.sessionAutoContinue === 'boolean') { s.sessionAutoContinue = body.sessionAutoContinue; } if (body.continueMode && CONTINUE_MODES.has(body.continueMode)) { s.continueMode = body.continueMode; if (body.continueMode !== 'standard') { s.preRun = null; } if (body.continueMode !== 'optimize') { s.optimizeRun = null; } } if (typeof body.timedReplyEnabled === 'boolean') { s.timedReplyEnabled = body.timedReplyEnabled; } if (typeof body.sessionTimedReply === 'boolean') { s.timedReplyEnabled = body.sessionTimedReply; s.sessionTimedReply = body.sessionTimedReply; } const applyTimedIntervalMs = (ms) => { if (!Number.isFinite(ms)) return; if (ms <= 0) { s.timedReplyIntervalMs = 0; s.sessionTimedIntervalMs = 0; } else if (ms >= TIMED_REPLY_MIN_MS) { const v = Math.floor(ms); s.timedReplyIntervalMs = v; s.sessionTimedIntervalMs = v; } }; if (body.timedReplyIntervalMs != null) { applyTimedIntervalMs(Number(body.timedReplyIntervalMs)); } if (body.sessionTimedIntervalMs != null) { applyTimedIntervalMs(Number(body.sessionTimedIntervalMs)); } if (body.displayTitle != null) { const t = String(body.displayTitle).trim().slice(0, 120); if (t) s.displayTitle = t; else delete s.displayTitle; } s.lastActiveAt = nowMs(); saveSessions(); if (pendingWaits.has(token)) { scheduleTimedReply(token); if (effectiveAutoContinue(s)) scheduleAutoContinue(token); } log('session config', token, { sessionAutoContinue: s.sessionAutoContinue, continueMode: getSessionContinueMode(s), timedReplyEnabled: effectiveTimedReply(s), timedReplyIntervalMs: getTimedReplyIntervalMs(s), }); return json(res, 200, { ok: true, token, sessionAutoContinue: effectiveAutoContinue(s), continueMode: getSessionContinueMode(s), continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)], timedReplyEnabled: effectiveTimedReply(s), timedReplyAuto: isTimedReplyAuto(s), timedReplyIntervalMs: getTimedReplyIntervalMs(s), timedReplyFireAt: pendingWaits.get(token)?.timedReplyFireAt || null, autoContinueDelayMs: hubSettings.autoContinueDelayMs, }); } if (u.pathname === '/api/reply' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, reply, cursorTitle: replyTitle } = body; if (!token || typeof reply !== 'string') { return json(res, 400, { ok: false, error: 'bad request' }); } const s = sessions.get(token); if (!s) { return json(res, 404, { ok: false, error: 'unknown token' }); } if (replyTitle) { const v = validateTokenBinding(token, s.workspace, replyTitle, s.hostIp || body.hostIp); if (!v.ok) { return json(res, 403, { ok: false, ...v }); } } if (!s) { return json(res, 404, { ok: false, error: 'unknown token' }); } if (!pendingWaits.has(token)) { const q = queueSteerReply(token, reply); if (!q.ok) return json(res, 400, q); return json(res, 200, q); } focusPanelTarget(token); deliverReply(token, reply); return json(res, 200, { ok: true, token }); } if (u.pathname === '/api/continue' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token } = body; if (!token) return json(res, 400, { ok: false, error: 'bad request' }); const result = triggerContinue(token, { manualContinue: true }); if (!result.ok) { const code = result.error === 'NOT_ACTIVE_WAIT_TOKEN' ? 403 : 404; return json(res, code, { ok: false, ...result }); } return json(res, 200, result); } if (u.pathname === '/api/wait/register' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, message, prompt, title, workspace, pid, hostIp: bodyHostIp, hub: bodyHub } = body; if (!token || !sessions.has(token)) { return json(res, 404, { ok: false, error: 'unknown token' }); } const s = sessions.get(token); const cursorTitle = title || s.cursorTitle || ''; const hostIp = normHostIp(bodyHostIp || s.hostIp); const hub = bodyHub || s.hub || hubEndpointLabel(); const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle, hostIp, { allowTitleRename: true, }); if (!v.ok) { log('wait register rejected', v); return json(res, 403, { ok: false, ...v }); } if (cursorTitle) { s.cursorTitle = cursorTitle; s.bindingKey = makeBindingKey(workspace || s.workspace, cursorTitle, hostIp); if (!s.title || s.title === '新对话') s.title = cursorTitle; } if (workspace) s.workspace = workspace; s.hostIp = hostIp; s.hub = hub; s.lastActiveAt = nowMs(); s.status = 'waiting'; markConnectionHeld(s); (s.messages = s.messages || []).push({ role: 'assistant', content: String(message || ''), at: nowMs() }); const review = parseKaruoReview(message); s.lastReview = { hasReview: review.hasReview, completionPct: review.completionPct, nextStep: review.nextStep, isIdleNext: review.isIdleNext, needsZhuiwen: review.needsZhuiwen, at: nowMs(), }; if (review.projectGoal) s.projectGoal = review.projectGoal.slice(0, 200); if (review.completionPct != null) s.completionPct = review.completionPct; if (!review.isIdleNext && review.nextStep) { if (getSessionContinueMode(s) === 'standard') initPreRun(s, review.nextStep); if (isOptimizeMode(s)) initOptimizeRun(s, review.nextStep); } if (/追问完成|验收通过|追问结束/.test(String(message || ''))) { advancePreRunOnReply(s, String(message || '')); } if (effectiveAutoContinue(s) && !isIdleAssistantWait(message)) { s.autoStreak = 0; } saveSessions(); registerBinding(workspace || s.workspace, cursorTitle, token, { hostIp, hub }); setActiveWaitToken(token); const old = pendingWaits.get(token); if (old) { clearWaitTimers(old); for (const r of old.pollers) { try { json(r, 200, { status: 'renewal', token }); } catch {} } } pendingWaits.set(token, { message: String(message || ''), prompt: prompt || '', title: title || '', createdAt: nowMs(), workspace: workspace || s.workspace, pid: pid || null, pollers: new Set(), resolve: null, autoTimer: null, timedReplyTimer: null, timedReplyFireAt: null, silent: isSilentMessage(message, token), }); log('wait register', token, { pid, autoContinue: effectiveAutoContinue(s), timedReply: effectiveTimedReply(s), timedIntervalMs: getTimedReplyIntervalMs(s), mode: getSessionContinueMode(s), silent: isSilentMessage(message, token), }); if (effectiveAutoContinue(s)) scheduleAutoContinue(token); if (effectiveTimedReply(s)) scheduleTimedReply(token); return json(res, 200, { ok: true, token, autoContinue: effectiveAutoContinue(s), continueMode: getSessionContinueMode(s), hostIp, hub, bindingKey: s.bindingKey, bindingCheckCount: s.bindingCheckCount || 0, bindingUnchanged: true, }); } if (u.pathname === '/api/wait/poll' && req.method === 'GET') { const token = u.searchParams.get('token'); if (!token) return json(res, 400, { error: 'missing token' }); const wait = pendingWaits.get(token); if (!wait) { return json(res, 404, { status: 'gone', error: 'no pending wait for token' }); } const steered = takeSteerReply(token); if (steered) { deliverReply(token, steered, { steer: true }); return json(res, 200, { status: 'replied', reply: steered, token }); } let done = false; const timer = setTimeout(() => { if (done) return; done = true; wait.pollers.delete(res); json(res, 200, { status: 'renewal', token }); }, POLL_TIMEOUT_MS); wait.pollers.add(res); res.on('close', () => { if (!done) { done = true; clearTimeout(timer); wait.pollers.delete(res); } }); return; } if (u.pathname === '/api/init' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const workspace = body.workspace || ''; const cursorTitle = body.cursorTitle || body.title || ''; const newPlan = !!(body.newPlan || body.force); const planLabel = String(body.planLabel || '').trim(); const hostIp = normHostIp(body.hostIp); const hub = body.hub || hubEndpointLabel(); let existing = findBoundSession(workspace, cursorTitle, hostIp, planLabel || null); if (newPlan && existing) { // 新计划只创建新的完整 token,不归档旧计划;旧 token 继续保持 holding/waiting。 log('init keep old for newPlan', existing, { planLabel }); existing = null; } if (existing && !newPlan) { const s = sessions.get(existing); s.lastActiveAt = nowMs(); if (cursorTitle) { s.cursorTitle = cursorTitle; s.bindingKey = makeBindingKey(workspace, cursorTitle, hostIp); } s.hostIp = hostIp; s.hub = hub; saveSessions(); registerBinding(workspace, cursorTitle, existing, { hostIp, hub, planLabel: planLabel || s.planLabel || s.title }); log('init reuse', existing, s.bindingKey); return json(res, 200, { ok: true, token: existing, reused: true, bindingKey: s.bindingKey, hostIp, hub, bindingMode: 'reuse', hint: '同标签+同IP已绑定此 token;换新计划请 newPlan:true', }); } const activeCount = Array.from(sessions.values()).filter((s) => s.status !== 'completed').length; if (activeCount > 0 && !body.force && !cursorTitle) { return json(res, 409, { ok: false, error: 'SESSION_LIST_NOT_EMPTY', count: activeCount, hint: '传 cursorTitle 复用/新建绑定;或用户明确「新建持久对话」时 force=true', }); } const token = newToken(); const sessionTitle = planLabel || cursorTitle || body.title || '新对话'; sessions.set(token, { token, title: sessionTitle, planLabel: planLabel || '', cursorTitle: cursorTitle || '', bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, hostIp) : '', hostIp, hub, workspace, status: 'holding', connectionHeld: true, phase: 'running', preRun: null, optimizeRun: null, sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode), timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false, timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null ? hubSettings.timedReplyIntervalMs : 0, bindingCheckCount: 0, bindingLockedAt: nowMs(), createdAt: nowMs(), lastActiveAt: nowMs(), messages: [], }); saveSessions(); dedupeBindingSessions(); registerBinding(workspace, cursorTitle, token, { hostIp, hub, planLabel: planLabel || sessionTitle }); log('init', token, { bindingKey: sessions.get(token).bindingKey, newPlan, planLabel, hostIp }); return json(res, 200, { ok: true, token, newPlan, planLabel, bindingKey: sessions.get(token).bindingKey, hostIp, hub, bindingMode: 'auto_new', }); } if (u.pathname === '/api/session/restore' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token } = body; if (!token) return json(res, 400, { ok: false, error: 'missing token' }); rebuildBindingRegistryFromThreadFiles(); let found = false; for (const [key, hit] of Object.entries(bindingRegistry)) { if (hit && hit.token === token) { if (!sessions.has(token)) { bindingRegistry[key] = hit; } found = true; break; } } if (!found) { if (fs.existsSync(THREAD_BINDINGS_DIR)) { for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { if (!name.endsWith('.json')) continue; try { const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); if (hit && hit.token === token && hit.bindingKey) { bindingRegistry[hit.bindingKey] = { token, hostIp: hit.hostIp || ipFromKey(hit.bindingKey), hub: hit.hub || hubEndpointLabel(), updatedAt: hit.updatedAt || nowMs(), }; saveBindingRegistry(); found = true; break; } } catch {} } } } if (!found && !sessions.has(token)) { return json(res, 404, { ok: false, error: 'no binding for token' }); } restoreSessionsFromBindings(); saveSessions(); return json(res, 200, { ok: true, token, restored: sessions.has(token) }); } if (u.pathname === '/api/bindings/rebuild' && req.method === 'POST') { const n = rebuildBindingRegistryFromThreadFiles(); restoreSessionsFromBindings(); return json(res, 200, { ok: true, rebuilt: n, sessions: sessions.size }); } if (u.pathname === '/api/binding/context' && req.method === 'GET') { const workspace = u.searchParams.get('workspace') || ''; const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; const hostIp = u.searchParams.get('hostIp') || ''; const planLabel = u.searchParams.get('planLabel') || ''; const ctx = buildBindingContext(workspace, cursorTitle, hostIp, planLabel); return json(res, 200, { ok: true, ...ctx }); } if (u.pathname === '/api/plan/bind' && req.method === 'GET') { const token = normalizeExactToken(u.searchParams.get('token') || ''); if (!token) { return json(res, 400, { ok: false, error: 'INVALID_TOKEN', hint: '须完整 ct_xxxxxxxx,禁止前缀泛搜' }); } const plan = getPlanByToken(token); if (!plan) { return json(res, 404, { ok: false, error: 'NO_PLAN_BINDING', token }); } return json(res, 200, { ok: true, ...plan }); } if (u.pathname === '/api/cleanup-unused' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const r = cleanupUnusedPlans({ keepTokens: body.keepTokens || [] }); return json(res, 200, { ok: true, ...r }); } if (u.pathname === '/api/binding/rename' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, cursorTitle, workspace, hostIp: bodyHostIp } = body; if (!token || !cursorTitle) { return json(res, 400, { ok: false, error: 'missing token or cursorTitle' }); } const mig = migrateBindingRename( workspace || sessions.get(token)?.workspace, token, cursorTitle, bodyHostIp || sessions.get(token)?.hostIp ); return json(res, 200, { ok: true, ...mig }); } if (u.pathname === '/api/session/verify' && req.method === 'GET') { const token = u.searchParams.get('token') || ''; const workspace = u.searchParams.get('workspace') || ''; const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; const hostIp = u.searchParams.get('hostIp') || ''; if (!token) { return json(res, 400, { ok: false, match: false, error: 'missing token' }); } const allowRename = u.searchParams.get('allowTitleRename') === '1'; if (!sessions.has(token)) { restoreSessionsFromBindings(); } const s = sessions.get(token); if (!s) { return json(res, 404, { ok: false, match: false, token, error: 'UNKNOWN_CONVERSATION_TOKEN', sessionExists: false, hint: 'Hub 会话库/绑定库都找不到该完整 token;此时才允许 init_conversation(newPlan:true) 新建计划。', }); } const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp, { allowTitleRename: allowRename }); const regToken = getRegistryToken(workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp); const match = v.ok && (!regToken || regToken === token); const tokenOwnerTitle = getTokenOwnerTitle(workspace || s.workspace, token); const payload = { ok: match, match, token, expectedToken: regToken || token, tokenOwnerTitle: tokenOwnerTitle || null, bindingKey: makeBindingKey(workspace, cursorTitle, hostIp), hostIp: normHostIp(hostIp || s?.hostIp), hub: s?.hub || hubEndpointLabel(), bindingUnchanged: !!match, bindingCheckCount: s?.bindingCheckCount || 0, sessionExists: sessions.has(token), ...(v.ok ? v : v), }; return json(res, match ? 200 : 403, payload); } if (u.pathname === '/api/session/goal' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, projectGoal, completionPct } = body; if (!token || !sessions.has(token)) { return json(res, 404, { ok: false, error: 'unknown token' }); } const s = sessions.get(token); if (typeof projectGoal === 'string') s.projectGoal = projectGoal.trim(); if (typeof completionPct === 'number' && !Number.isNaN(completionPct)) { s.completionPct = Math.max(0, Math.min(100, completionPct)); } s.lastActiveAt = nowMs(); saveSessions(); return json(res, 200, { ok: true, token, projectGoal: s.projectGoal || '', completionPct: s.completionPct ?? null, }); } if (u.pathname === '/api/binding' && req.method === 'GET') { const workspace = u.searchParams.get('workspace') || ''; const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; const hostIp = u.searchParams.get('hostIp') || ''; const planLabel = u.searchParams.get('planLabel') || ''; const token = (planLabel ? getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) : null) || getRegistryToken(workspace, cursorTitle, hostIp) || findBoundSession(workspace, cursorTitle, hostIp, planLabel || null); if (!token) { return json(res, 404, { ok: false, error: 'no binding', bindingKey: makeBindingKey(workspace, cursorTitle, hostIp), planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : '', hostIp: normHostIp(hostIp), hub: hubEndpointLabel(), }); } const s = sessions.get(token); return json(res, 200, { ok: true, token, planLabel: s?.planLabel || planLabel || '', bindingKey: makeBindingKey(workspace, cursorTitle, hostIp || s?.hostIp), planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : (s?.planBindingKey || ''), hostIp: s?.hostIp || normHostIp(hostIp), hub: s?.hub || hubEndpointLabel(), title: s?.title || cursorTitle, workspace: s?.workspace || workspace, }); } if (u.pathname === '/api/new' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const workspace = body.workspace || ''; const cursorTitle = body.cursorTitle || body.title || '新对话'; const token = newToken(); sessions.set(token, { token, title: cursorTitle, cursorTitle, bindingKey: makeBindingKey(workspace, cursorTitle), workspace, status: 'holding', connectionHeld: true, sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, createdAt: nowMs(), lastActiveAt: nowMs(), messages: [], }); saveSessions(); pruneSessions(); return json(res, 200, { ok: true, token }); } if (u.pathname === '/api/steer/delete' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, index, all } = body; if (!token || !sessions.has(token)) { return json(res, 404, { ok: false, error: 'unknown token' }); } const s = sessions.get(token); if (!Array.isArray(s.steerQueue) || !s.steerQueue.length) { return json(res, 404, { ok: false, error: 'empty', hint: '无排队方向可删' }); } let removed = 0; if (all) { const texts = s.steerQueue.map((i) => i.text); removed = s.steerQueue.length; s.steerQueue = []; pruneSteerFromMessages(s, texts); } else if (Number.isInteger(index) && index >= 0 && index < s.steerQueue.length) { const text = s.steerQueue[index].text; s.steerQueue.splice(index, 1); if (text) pruneSteerFromMessages(s, [text]); removed = 1; } else { return json(res, 400, { ok: false, error: 'bad index' }); } s.lastActiveAt = nowMs(); saveSessions(); log('steer delete', token, { removed, remain: (s.steerQueue || []).length }); return json(res, 200, { ok: true, token, removed, queueLen: (s.steerQueue || []).length, }); } if (u.pathname === '/api/delete' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const { token, force } = body; if (!token) return json(res, 400, { ok: false, error: 'missing token' }); const r = deleteSession(token, !!force); if (!r.ok) return json(res, 409, r); pruneSessions(); return json(res, 200, r); } if (u.pathname === '/api/delete-idle' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const keep = body.keep || null; const maxIdleHours = Math.max(1, Number(body.maxIdleHours) || 72); const maxIdleMs = maxIdleHours * 3600000; const now = nowMs(); const protectedTokens = new Set(pendingWaits.keys()); if (keep) protectedTokens.add(keep); for (const hit of Object.values(bindingRegistry)) { if (hit && hit.token && pendingWaits.has(hit.token)) protectedTokens.add(hit.token); } let deleted = 0; const deletedTokens = []; for (const [token, s] of [...sessions.entries()]) { if (protectedTokens.has(token)) continue; if (pendingWaits.has(token)) continue; const age = now - (s.lastActiveAt || s.createdAt || now); const idleLike = s.status === 'idle' || s.status === 'completed' || !(s.messages || []).length; if (!idleLike) continue; if (age < maxIdleMs && (s.messages || []).length > 0) continue; if (isVerticallyLockedToken(token) && age < maxIdleMs * 2) continue; sessions.delete(token); deletedTokens.push(token); deleted++; } if (deleted) saveSessions(); pruneSessions(); log('delete-idle', { deleted, remain: sessions.size, maxIdleHours, deletedTokens }); return json(res, 200, { ok: true, deleted, remain: sessions.size, maxIdleHours }); } if (u.pathname === '/api/prune' && req.method === 'POST') { const n = pruneSessions(); return json(res, 200, { ok: true, pruned: n, remain: sessions.size }); } if (u.pathname === '/api/merge' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}'); const src = sessions.get(body.source); const tgt = sessions.get(body.target); if (!src || !tgt) return json(res, 400, { ok: false, error: 'invalid token' }); (tgt.messages = tgt.messages || []).push(...(src.messages || [])); src.status = 'completed'; tgt.lastActiveAt = nowMs(); saveSessions(); return json(res, 200, { ok: true }); } res.writeHead(404); res.end('not found'); } catch (e) { log('http err', String(e.stack || e.message || e)); try { json(res, 500, { error: String(e.message || e) }); } catch {} } }); server.on('error', (err) => { log('server listen error', err && err.code, err && err.message); process.exit(1); }); server.listen(HTTP_PORT, '127.0.0.1', () => { log('hub listening', HTTP_PORT, 'pid', process.pid); try { archiveStaleSessions(); } catch (e) { log('archive boot err', String(e.message || e)); } }); setInterval(() => { try { archiveStaleSessions(); } catch (e) { log('archive tick err', String(e.message || e)); } }, 5 * 60 * 1000); setInterval(() => { let changed = false; for (const [token, s] of sessions.entries()) { if (s.status === 'completed') continue; if (!effectiveAutoContinue(s)) continue; if (pendingWaits.has(token)) { if (!s.connectionHeld) { s.connectionHeld = true; changed = true; } s.lastKeepAliveAt = nowMs(); if (s.status !== 'waiting') { s.status = 'waiting'; changed = true; } } else { if (s.connectionHeld) { s.connectionHeld = false; changed = true; } if (s.status === 'holding') { s.status = 'idle'; changed = true; } } } if (changed) saveSessions(); }, CONNECTION_KEEPALIVE_MS); process.on('SIGTERM', () => process.exit(0)); process.on('SIGINT', () => process.exit(0));