/
threed
/
claud-web
Обзор
Документация
Войти
/
threed
/
claud-web
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
lib/watch.js
172 строки
6 KB
Alexander Zelenski
Уведомления о завершении работы Claude, стабильный вход и PWA
04 авг 2026, 14:26
04 авг 2026, 14:26
b6ac18e
Код
Авторство
О чём код?
/** * Вотчер сессий Claude Code: ловит момент «Клод закончил работу». * * Claude Code — TUI без API состояния, поэтому смотрим на его экран: пока * идёт работа, внизу висит строка-спиннер вида * ✽ Osmosing… (1m 59s · ↓ 6.9k tokens) * или с подсказкой «(esc to interrupt)». Когда работа закончена, строка * исчезает и остаётся приглашение ввода. * * Каждые POLL_MS снимаем экраны всех cw-сессий (tmux capture-pane) и ищем * маркер. Переход busy → idle = «Клод закончил» → событие в Socket.IO * и Web Push подписчикам. * * Защита от ложных срабатываний: * - idle подтверждается IDLE_CONFIRM_TICKS подряд (спиннер мигает и между * шагами инструментов может пропасть на один кадр); * - уведомляем только если работа шла дольше MIN_BUSY_MS (дёргать пушем * из-за мгновенного ответа незачем). */ const terminal = require('./terminal'); const projects = require('./projects'); const push = require('./push'); const POLL_MS = 3000; const IDLE_CONFIRM_TICKS = 2; // ~6 c тишины до вывода «закончил» const MIN_BUSY_MS = 15000; // короче — не уведомляем const TAIL_LINES = 25; // спиннер всегда внизу экрана // Маркер работающего Claude: подсказка прерывания либо «…(<время>s» const BUSY_RE = /(esc to interrupt)|…\s*\((?:\d+h\s*)?(?:\d+m\s*)?\d+s(?:\s|·|\)|$)/; // Состояние по сессиям: name -> { busy, busySince, idleTicks } const state = new Map(); let io = null; let timer = null; // Предикат «на эту сессию сейчас кто-то смотрит» — задаётся из server.js let isWatched = () => false; function setIO(instance) { io = instance; } function setWatchedPredicate(fn) { if (typeof fn === 'function') isWatched = fn; } /** Работает ли Claude прямо сейчас (по видимому экрану сессии). */ function isBusy(pane) { if (!pane) return false; const tail = pane.split('\n').slice(-TAIL_LINES); return tail.some(line => BUSY_RE.test(line)); } /** Человекочитаемая длительность работы: «2 мин 5 с». */ function formatDuration(ms) { const total = Math.round(ms / 1000); const min = Math.floor(total / 60); const sec = total % 60; return min ? `${min} мин ${sec} с` : `${sec} с`; } /** Имя проекта по его id — для текста уведомления. */ function projectName(projectId) { return projects.get(projectId)?.name || projectId; } async function tick() { let sessions = []; try { sessions = await terminal.listAllSessions(); } catch (err) { console.warn('watch: не удалось получить список сессий:', err.message); return; } const alive = new Set(sessions.map(s => s.name)); for (const name of state.keys()) { if (!alive.has(name)) state.delete(name); } for (const session of sessions) { const pane = await terminal.capturePane(session.name); // Пустой экран = сессия исчезла между list и capture: пропускаем такт, // иначе «пропавший спиннер» прочитался бы как завершение работы if (!pane) continue; const busy = isBusy(pane); const prev = state.get(session.name) || { busy: false, busySince: 0, idleTicks: 0 }; if (busy) { state.set(session.name, { busy: true, busySince: prev.busy ? prev.busySince : Date.now(), idleTicks: 0 }); continue; } if (!prev.busy) { state.set(session.name, prev); continue; } // Спиннер пропал — ждём подтверждения, он мог мигнуть между шагами const idleTicks = prev.idleTicks + 1; if (idleTicks < IDLE_CONFIRM_TICKS) { state.set(session.name, { ...prev, idleTicks }); continue; } const workedMs = Date.now() - prev.busySince; state.set(session.name, { busy: false, busySince: 0, idleTicks: 0 }); if (workedMs >= MIN_BUSY_MS) { notifyFinished({ ...session, workedMs }); } } } function notifyFinished(session) { const title = `Claude ${session.index} · ${projectName(session.projectId)}`; const body = `Работа завершена за ${formatDuration(session.workedMs)}`; // Открытым вкладкам — через сокет (мгновенно, без разрешения на пуши) io?.emit('claude:idle', { session: session.name, projectId: session.projectId, index: session.index, workedMs: session.workedMs, title, body }); // Пользователь смотрит именно на эту вкладку — пуш только помешает if (isWatched(session.name)) return; // Свёрнутому PWA / закрытому браузеру — Web Push push.broadcast({ title, body, tag: session.name }); } /** Текущее состояние сессий для UI: { "<session>": true } — где Клод работает. */ function busyMap() { const out = {}; for (const [name, s] of state) { if (s.busy) out[name] = true; } return out; } function start() { if (timer) return; // Такты не накладываются: следующий планируем после завершения текущего const loop = async () => { try { await tick(); } catch (err) { console.warn('watch:', err.message); } timer = setTimeout(loop, POLL_MS); timer.unref?.(); }; timer = setTimeout(loop, POLL_MS); timer.unref?.(); } function stop() { clearTimeout(timer); timer = null; } module.exports = { setIO, setWatchedPredicate, start, stop, busyMap, isBusy };