В два часа ночи ты упираешься в лимит модели. Сессия на середине, мысль на середине, и до сброса окна ещё полтора часа. Ты сидишь и смотришь в терминал, где написано, сколько ты потратил токенов, сколько это стоило в долларах и сколько процентов контекста съедено.
Чего там не написано — что прямо сейчас ещё шестеро сидят и ждут того же сброса. И четверо из них тоже глубоко за полночь.
Про это и получился инструмент. Одна строка в статуслайне Claude Code:
день 36 · 9 в консоли · 4 не спят · 6 ждут сброса
Ниже — почему такая строка, что именно уходит с машины (спойлер: четыре числа), почему рендер укладывается в 26 мс без единой зависимости и на какие грабли я наступил по дороге. Код открыт, ставится одной командой, сносится тоже одной.
Проблема: все существующие плагины показывают тебя самого
Статуслайнов для Claude Code написан десяток: ccstatusline с powerline и темами, claude-statusline с прогресс-барами расхода, варианты с TOML-конфигами. Они разные и хорошо сделанные, но все отвечают на один и тот же вопрос — сколько ты потратил. Токены, деньги, контекст, время до сброса.
Работа с ИИ-агентом при этом устроена так, что ты в комнате один. Нет коллег за соседним столом, нет общего чата, где кто-то в три ночи пишет «я тоже ещё тут». Есть только твои цифры и твой лимит.
Мне не нужен был ещё один трекер расхода. Мне нужен был сигнал присутствия — и это принципиально другой продукт, даже если он живёт в той же строке терминала.
Отсюда рамка, которую я записал в ТЗ до первой строчки кода: никаких аккаунтов, ников, друзей, чата, лидерборда, истории, графиков и веб-морды. Всё это уже сделано другими, и добавление любого пункта превращает вещь обратно в трекер, то есть в проигранную конкуренцию.
Что показывать
Три сигнала, все про людей, а не про тебя:
рядом — сколько человек с сопоставимым стриком сейчас в консоли;
не спят — у скольких из них сейчас ночь;
ждут сброса — сколько упёрлись в лимит.
Плюс собственный стрик — он считается локально и работает даже когда сервер лежит и когда пользователей ноль. Это то, что держит холодный старт: инструмент не выглядит сломанным в первый день, когда в нём никого нет.
«Сопоставимый стрик» — не украшение, а способ сделать число осмысленным. Полоса ±25%, но не уже ±3 дней:
function range(streak) { return { lo: Math.min(streak - 3, Math.floor(streak * 0.75)), hi: Math.max(streak + 3, Math.ceil(streak * 1.25)), }; } // range(70) -> 52..88 range(5) -> 2..8
При стрике 70 в когорту попадают 52–88, при стрике 5 — только 2–8. Человек на пятый день не видит толпу ветеранов, ветеран не видит толпу новичков. Себя в свою же когорту не считаем.
И одно правило, которое я считаю важнее всех остальных: нулевой блок не печатается никогда.
if (c.near > 0) parts.push(w.near(c.near)); if (c.night > 0) parts.push(w.night(c.night)); if (c.limited > 0) parts.push(w.limit(c.limited));
«рядом 0» — это не нейтральная цифра, а сообщение «ты один», то есть ровно то, от чего инструмент должен спасать. Блок исчезает целиком, включая середину строки.
Что уходит с машины
Это главный вопрос доверия, поэтому он же первый раздел README. С машины уходит ровно это:
{ "id": "<hex, 16 случайных байт>", "streak": 36, "state": "work", "night": false }
Четыре поля. id генерируется один раз в ~/.claude/presence/id с правами 0600 и не выводится ни из чего идентифицирующего — это просто случайные байты. Не уходят: промпты, пути, имена файлов и репозиториев, названия моделей, git-ветки, cwd, версия ОС.
Отдельно про night. Соблазн был отправить часовой пояс — с ним можно было бы рисовать красивую карту «где сейчас работают». Вместо этого едет один бит, посчитанный на машине пользователя:
// Один бит, а не часовой пояс: 23:00-06:00 по локальным часам. function isNight(now = Date.now()) { const h = new Date(now).getHours(); return h >= 23 || h < 6; }
Сервер знает «у него сейчас ночь» и не знает, где он и сколько у него на часах. Аудитория таких инструментов первым делом читает исходники и смотрит, что именно улетает; один непрозрачный байт убивает доверие быстрее любого бага.
Сервер держит Map в памяти с TTL 120 секунд. Ни базы, ни диска, ни аккаунтов. Перезапуск — все переподключаются за 45 секунд, и это нормально: присутствие эфемерно по своей природе, хранить его историю было бы враньём про суть.
Три процесса, и почему они разделены
Архитектура — главное решение всего проекта.
statusline.js — рендер. Запускается на каждый кадр строки, получает JSON на stdin. Инварианты жёсткие:
никогда не ходит в сеть — ни одного вызова, ни при каких условиях;
никогда не бросает исключение наружу: любая ошибка = пустой вывод и
exit 0;никогда не сканирует файловую систему дальше двух известных файлов.
Третий пункт неочевиден, но именно он держит бюджет. А второй — терминал пользователя: сломанный статуслайн ломает весь вид консоли, и человек снесёт плагин, не разбираясь.
function main() { try { const input = JSON.parse(fs.readFileSync(0, 'utf8')); const p = usedPct(input); fs.writeFileSync(STATE, JSON.stringify({ state: p !== null && p >= 95 ? 'limit' : 'work', ts: Date.now(), })); } catch {} try { const line = render(JSON.parse(fs.readFileSync(COHORT, 'utf8')), Date.now()); if (line) process.stdout.write(line); } catch {} }
pingd.js — единственный, кто ходит в сеть. Раз в 45 секунд считает стрик, читает состояние, отправляет четыре поля, кладёт ответ в cohort.json. Запись обязательно через временный файл и renameSync, иначе статуслайн однажды прочитает недописанный JSON. Таймаут 4 секунды через AbortSignal.timeout, любая сетевая ошибка проглатывается молча — пользователю про неё знать незачем.
server.js — один эндпоинт POST /ping, Map в памяти, ответ с тремя числами.
Разделение даёт свойство, ради которого всё и затевалось: сеть физически не может затормозить отрисовку строки. Убейте пингер — статуслайн продолжит показывать стрик. Уроните сервер — строка станет короче, но не сломается.
26 мс: почему ноль зависимостей — это арифметика, а не аскеза
Бюджет по ТЗ — 50 мс на рендер, цель 30. Замерил 20 прогонов, медиана — 26 мс. Дальше интереснее: из этих 26 мс собственная работа скрипта (два readFileSync, один writeFileSync, склейка строки) занимает единицы миллисекунд. Всё остальное — старт процесса node.
Это переворачивает всю оптимизацию. Статуслайн не работает как сервер: он не живёт, а рождается и умирает сотни раз в день. Нет прогретого кэша, нет долгоживущей кучи, JIT не успевает окупиться. В такой форме один require стороннего пакета стоит дороже, чем вся полезная работа процесса за его жизнь.
Поэтому «ноль зависимостей» здесь не идеология, а расчёт: любая удобная библиотека удвоила бы время ради экономии пятнадцати строк кода.
Обобщение, которое стоит унести: сначала измерьте пол, потом оптимизируйте работу. И понимайте, в какой вы форме — серверный код размазывает импорты по миллионам запросов, а код «процесс на вызов» (CLI, git-хуки, промпты шелла, плагины редакторов, шаги CI) платит за них каждый раз.
Грабли по дороге
Стрик по mtime врал на 16 дней. Очевидный способ посчитать «дни подряд» — обойти файлы сессий и собрать уникальные даты по времени изменения. Получилось 20 дней против 36 в интерфейсе. Причина: продолжение сессии (--continue) дописывает старый файл и переносит его mtime вперёд, а день, когда сессия началась, исчезает бесследно. При сессиях длиной в две недели это происходит постоянно.
Правильный источник лежал в соседнем файле: приложение само ведёт список активных дней в своём кэше. Один readFile на 21 КБ вместо обхода 776 МБ — и число совпало с интерфейсом. Мораль шире случая: mtime отвечает на вопрос «когда файл трогали последний раз», а не «когда произошло событие», и для любых данных, которые дописываются, это разные вопросы.
Имя поля, которого не было в документации. Состояние limit берётся из процента расхода пятичасового окна. Я перебирал used_pct, usedPct, utilization, used_percent — и состояние не срабатывало никогда. Помог десятистрочный dump.js, который вешается на statusLine на один рендер и сохраняет сырой stdin в файл. Реальное имя оказалось used_percentage.
Вывод банальный, но я его каждый раз заново выучиваю: прежде чем гадать о формате входных данных, сохраните их и посмотрите. Отладочная утилита на десять строк окупается в первые же пять минут.
Что в итоге
Node 20+, ноль зависимостей, три файла, которые запускаются как есть. Ставится одной командой, сносится тоже одной — вместе со всеми следами, включая запись в настройках.
npx cc-presence
Метрика у проекта одна, и она не про установки: стоит ли плагин на седьмой день. Снести легко, поставить из любопытства ещё легче; единственное честное доказательство — что человек не убрал строку через неделю.
Пока в этом Map на сервере живу в основном я сам. Если у вас после чтения осталось ощущение, что вы эту комнату знаете — самое время сделать её чуть менее пустой.
Код и подробности: github.com/mishokU/cc-presence (MIT).
yarkov
Зачем?