Files
persistent-chat-plugin/src/hub.js
卡若AI ba786b3700 feat: Persistent Chat v1.1 CO-Chat 整合上传 Gitea
同步 enhance/bridge/smoke/retire 模块、vendor 3.3.34、增强面板与 PRD 文档,
Hub 接入 hub-routes-prd,含安装/经验/模块说明与 co-integration 运维脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:42:16 +08:00

2502 lines
92 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/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');
let prdRoutes;
try { prdRoutes = require('./lib/hub-routes-prd'); } catch (e) { /* lib 未同步时跳过 */ }
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: '【保活续跑】保持连接,继续执行当前计划与学习清单,直推开发直到目标完成。',
quotaMonitorEnabled: false,
quotaWarnThresholdPct: 85,
quotaStatusFile: '',
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));
}
}
function resolveQuotaStatusFile() {
const custom = String(hubSettings.quotaStatusFile || '').trim();
if (custom) return custom;
return path.join(ROOT, 'quota-status.json');
}
function readQuotaStatus() {
const file = resolveQuotaStatusFile();
const base = {
enabled: !!hubSettings.quotaMonitorEnabled,
thresholdPct: Number(hubSettings.quotaWarnThresholdPct) || 85,
sourceFile: file,
sample: null,
warning: false,
healthy: false,
};
try {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const usedPct = Number(raw.usedPct);
const remainingPct = Number.isFinite(raw.remainingPct) ? Number(raw.remainingPct) : (Number.isFinite(usedPct) ? Math.max(0, 100 - usedPct) : null);
const normalized = {
usedPct: Number.isFinite(usedPct) ? Math.max(0, Math.min(100, usedPct)) : null,
remainingPct: Number.isFinite(remainingPct) ? Math.max(0, Math.min(100, remainingPct)) : null,
resetAt: raw.resetAt || '',
source: raw.source || 'local-file',
checkedAt: raw.checkedAt || '',
notes: raw.notes || '',
};
base.sample = normalized;
base.warning = base.enabled && normalized.usedPct != null && normalized.usedPct >= base.thresholdPct;
base.healthy = normalized.usedPct != null;
} catch {}
return base;
}
function buildHandoffSummary(token) {
const s = sessions.get(token);
if (!s) return null;
const pending = pendingWaits.get(token);
const recent = (s.messages || []).slice(-6).map((m) => {
const who = m.role === 'assistant' ? '助手' : '用户';
const text = String(m.content || '').replace(/```text|```/g, '').replace(/\s+/g, ' ').trim();
return `- ${who}: ${text.slice(0, 160)}${text.length > 160 ? '…' : ''}`;
});
const pct = typeof s.completionPct === 'number' ? `${s.completionPct}%` : '未知';
const phase = s.phase || 'running';
const waiting = pending ? '是' : '否';
return [
`会话: ${s.displayTitle || s.cursorTitle || s.title || token}`,
`token: ${token}`,
`计划: ${s.planLabel || '未命名计划'}`,
`阶段: ${phase}`,
`进度: ${pct}`,
`目标: ${s.projectGoal || '未记录'}`,
`当前状态: ${pending ? '等待用户回复' : (s.status || 'idle')}`,
`是否挂起: ${waiting}`,
pending?.prompt ? `挂起提示: ${pending.prompt}` : '',
pending?.message ? `挂起消息: ${String(pending.message).replace(/\s+/g, ' ').trim().slice(0, 240)}` : '',
recent.length ? '最近上下文:' : '最近上下文: 无',
...recent,
'建议续接动作: 先 select_conversation再基于以上摘要继续执行最后 wait_for_user_input。',
].filter(Boolean).join('\n');
}
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.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}。按复盘与▶下一步执行,保持连接勿停。`;
}
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 结构化追问(目标/边界/验收23 轮)。` +
`在本轮正文输出追问清单与结论,复盘后 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 quota = readQuotaStatus();
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,
})),
quotaWarning: quota.warning,
});
}
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') {
const quota = readQuotaStatus();
return json(res, 200, {
ok: true,
pid: process.pid,
waiting: pendingWaits.size,
activeWaitToken,
panelVersion: readPanelVersion(),
quota,
});
}
if (u.pathname === '/' || u.pathname === '/panel') {
let html = fs.readFileSync(PANEL_HTML, 'utf8');
const boot = JSON.stringify({
sessions: buildStateList(),
settings: hubSettings,
panelVersion: readPanelVersion(),
activeWaitToken,
quota: readQuotaStatus(),
}).replace(/</g, '\\u003c');
html = html.replace('</head>', `<script>window.__PCHAT_BOOT__=${boot};</script>\n</head>`);
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,
quota: readQuotaStatus(),
});
}
if (u.pathname === '/api/quota' && req.method === 'GET') {
return json(res, 200, { ok: true, quota: readQuotaStatus() });
}
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) } : {}),
...(typeof body.quotaMonitorEnabled === 'boolean' ? { quotaMonitorEnabled: body.quotaMonitorEnabled } : {}),
...(body.quotaWarnThresholdPct != null ? { quotaWarnThresholdPct: Number(body.quotaWarnThresholdPct) } : {}),
...(body.quotaStatusFile != null ? { quotaStatusFile: String(body.quotaStatusFile) } : {}),
};
saveSettings();
log('settings updated', hubSettings);
return json(res, 200, { ok: true, settings: hubSettings });
}
if (u.pathname === '/api/quota' && req.method === 'POST') {
const body = JSON.parse(await readBody(req) || '{}');
const file = resolveQuotaStatusFile();
const payload = {
usedPct: body.usedPct != null ? Number(body.usedPct) : null,
remainingPct: body.remainingPct != null ? Number(body.remainingPct) : null,
resetAt: body.resetAt || '',
source: body.source || 'manual',
checkedAt: body.checkedAt || new Date().toISOString(),
notes: body.notes || '',
};
fs.writeFileSync(file, JSON.stringify(payload, null, 2));
return json(res, 200, { ok: true, quota: readQuotaStatus() });
}
if (u.pathname === '/api/session/prepare-handoff' && req.method === 'POST') {
const body = JSON.parse(await readBody(req) || '{}');
const token = String(body.token || '').trim();
if (!token || !sessions.has(token)) {
return json(res, 404, { ok: false, error: 'unknown token' });
}
const summary = buildHandoffSummary(token);
return json(res, 200, { ok: true, token, summary });
}
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 });
}
if (prdRoutes) {
const handled = await prdRoutes.handlePrdRoutes({
u, req, res, sessions, pendingWaits, saveSessions, nowMs, json, readBody,
});
if (handled) return;
}
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, '0.0.0.0', () => {
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));