2390 lines
87 KiB
JavaScript
Executable File
2390 lines
87 KiB
JavaScript
Executable File
#!/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(/</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,
|
||
});
|
||
}
|
||
|
||
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));
|