В два часа ночи ты упираешься в лимит модели. Сессия на середине, мысль на середине, и до сброса окна ещё полтора часа. Ты сидишь и смотришь в терминал, где написано, сколько ты потратил токенов, сколько это стоило в долларах и сколько процентов контекста съедено.

Чего там не написано — что прямо сейчас ещё шестеро сидят и ждут того же сброса. И четверо из них тоже глубоко за полночь.

Про это и получился инструмент. Одна строка в статуслайне 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 /pingMap в памяти, ответ с тремя числами.

Разделение даёт свойство, ради которого всё и затевалось: сеть физически не может затормозить отрисовку строки. Убейте пингер — статуслайн продолжит показывать стрик. Уроните сервер — строка станет короче, но не сломается.

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_pctusedPctutilizationused_percent — и состояние не срабатывало никогда. Помог десятистрочный dump.js, который вешается на statusLine на один рендер и сохраняет сырой stdin в файл. Реальное имя оказалось used_percentage.

Вывод банальный, но я его каждый раз заново выучиваю: прежде чем гадать о формате входных данных, сохраните их и посмотрите. Отладочная утилита на десять строк окупается в первые же пять минут.

Что в итоге

Node 20+, ноль зависимостей, три файла, которые запускаются как есть. Ставится одной командой, сносится тоже одной — вместе со всеми следами, включая запись в настройках.

npx cc-presence

Метрика у проекта одна, и она не про установки: стоит ли плагин на седьмой день. Снести легко, поставить из любопытства ещё легче; единственное честное доказательство — что человек не убрал строку через неделю.

Пока в этом Map на сервере живу в основном я сам. Если у вас после чтения осталось ощущение, что вы эту комнату знаете — самое время сделать её чуть менее пустой.

Код и подробности: github.com/mishokU/cc-presence (MIT).

Комментарии (1)


  1. yarkov
    07.09.2026 17:52

    Зачем?