local-kanban 1.16.0 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ version it goes out as and opens an empty one again. Released sections are histo
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.0.0-beta.1]
12
+
13
+ **2.0 beta.** The board stopped being a list of tasks next to Claude and became the place you work with it: Claude starts
14
+ inside the board, you talk to it in the card, watch every session side by side, and a fresh Claude checks the work. A
15
+ beta goes to everyone, like a release: `npm install -g local-kanban` and the update button both bring it, and your
16
+ data stays. To stay on the last 1.x, run `npm install -g local-kanban@1.16.0`.
17
+
18
+ ### Changed
19
+
20
+ - **The README shows the board as it is now**, in the dark theme, with Claude moving cards in the first picture.
21
+ - **Update checks understand betas.** A board on a beta is offered the next beta and the release it leads to; before,
22
+ both looked like the version it already had.
23
+
24
+ - **The chat says what Claude is doing while nothing lands in the transcript.** A long script takes a minute or two to
25
+ write, and all that time the chat said only "Claude is working…". The light is green now and tells how long the
26
+ transcript has been silent and, for a Claude inside the board, whether it is thinking or writing and how many tokens
27
+ it wrote. A counter that stood still for a minute with nothing to wait on turns the light yellow: "no answer for 1 min".
28
+ - **Steps tell writing from running.** A step and a folded run of steps say how long Claude took to write them and how
29
+ long they ran: "wrote for 1 min 31 s · ran for 19 s". Before, only the second number was shown, and a session of
30
+ minutes looked like commands of seconds.
31
+ - **Health lists the rules with `paths:` too.** The rules of a project's `.claude/rules` that Claude Code loads only
32
+ when it works with their files are listed with the CLAUDE.md files of subfolders, with their size: past the
33
+ per-file limit Claude Code reads only the start of such a file.
34
+
11
35
  ## [1.16.0]
12
36
 
13
37
  ### Added
package/README.md CHANGED
@@ -1,14 +1,99 @@
1
- # local-kanban
1
+ <h1 align="center">
2
+ <a href="https://www.npmjs.com/package/local-kanban"><img src="public/icon-192.png" alt="local-kanban" width="72" valign="middle" /></a> local-kanban
3
+ <br>
4
+ The kanban board Claude Code works from
5
+ </h1>
2
6
 
3
- [![tests](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml/badge.svg)](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml)
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/local-kanban"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/npm/local-kanban.svg?variant=secondary&mode=light" alt="Version on npm" /></picture></a>
9
+ <a href="https://github.com/lamaohub/local-kanban/releases/latest"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/release/lamaohub/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/github/release/lamaohub/local-kanban.svg?variant=secondary&mode=light" alt="Latest release" /></picture></a>
10
+ <a href="LICENSE"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/license/lamaohub/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/github/license/lamaohub/local-kanban.svg?variant=secondary&mode=light" alt="License" /></picture></a>
11
+ <a href="#requirements"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/platforms-macOS%20%C2%B7%20Linux.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/badge/platforms-macOS%20%C2%B7%20Linux.svg?variant=secondary&mode=light" alt="Runs on macOS and Linux" /></picture></a>
12
+ </p>
4
13
 
5
- A local kanban board built for working **in tandem with Claude Code**: the human plans and accepts tasks, Claude takes them from the queue, does the work and reports back — all through a token-efficient CLI called `kb`. Optionally mirrors everything to GitHub (issues + Projects v2).
14
+ <h3 align="center"><a href="#install"><ins>Install</ins></a> &nbsp;·&nbsp; <a href="#how-you-use-it">How it works</a> &nbsp;·&nbsp; <a href="CHANGELOG.md">What's new</a> &nbsp;·&nbsp; <a href="README.ru.md">Русская версия</a></h3>
6
15
 
7
- Русская версия — [README.ru.md](README.ru.md).
16
+ <p align="center">
17
+ <img width="960" src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/board.gif" alt="Claude moves cards across the board on its own: To do, Prep, Doing, Deploying, Review" />
18
+ </p>
8
19
 
9
- ![Board](docs/screenshot-board.png)
20
+ You plan and accept the work, Claude takes it from the queue, does it and reports back. Everything lives in one SQLite file on your machine and is driven by `kb`, a CLI that costs Claude few tokens. GitHub is an optional mirror, and nothing leaves your computer until you turn it on.
10
21
 
11
- ![Dashboard](docs/screenshot-dashboard.png)
22
+ > [!NOTE]
23
+ > **2.0 is in beta, and it is what `npm install -g local-kanban` gives you.** It has been in daily use on real projects. Your board updates in place and keeps its data; to stay on the last 1.x, run `npm install -g local-kanban@1.16.0`. Found something broken? [Open an issue](https://github.com/lamaohub/local-kanban/issues).
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ npm install -g local-kanban
29
+ local-kanban # setup wizard: Enter-Enter-Enter is enough
30
+ local-kanban start # board at http://localhost:3100
31
+ ```
32
+
33
+ Needs Node.js 22 or newer on macOS or Linux. Details are under [Install](#install).
34
+
35
+ ## Features
36
+
37
+ <h3 align="center">▶️ Press play, Claude starts working</h3>
38
+
39
+ <p align="center">One button on <b>To do</b> hands the column to Claude Code, right inside the board: no window opens and no prompt is pasted. Claude takes each task, moves its card as it goes, commits, deploys if the project has a deploy skill, and stops at <b>Review</b>. Done is always&nbsp;your&nbsp;word.</p>
40
+
41
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/claude-now.png" alt="Three Claude sessions working side by side in Claude now, each with its chat" width="100%" /></p>
42
+
43
+ <br/>
44
+ <h3 align="center">💬 Talk to Claude from&nbsp;the&nbsp;card</h3>
45
+
46
+ <p align="center">Every task keeps its own chat: what Claude said, its steps folded, the files it changed, and how long it has been silent. Answer its questions and permissions there, send pictures, queue your next words while it works, or switch to its&nbsp;terminal.</p>
47
+
48
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/card-chat.png" alt="A task card with the chat of its Claude: the steps, the changed files and a field to answer in" width="100%" /></p>
49
+
50
+ <br/>
51
+ <h3 align="center">🔎 A fresh Claude checks&nbsp;the&nbsp;work</h3>
52
+
53
+ <p align="center">Let <b>Review</b> pile up, then press one button. <b>Claude review</b> makes a check task and starts a Claude that did not write the code: it tests every task, tries to break it, moves what survives to Done and leaves a report. <b>Automations</b> can start it the moment a&nbsp;project&nbsp;goes&nbsp;quiet.</p>
54
+
55
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/claude-review.png" alt="Claude review lists the tasks waiting in Review by project, with a Check in Claude button" width="88%" /></p>
56
+
57
+ <br/>
58
+ <h3 align="center">📊 A dashboard that&nbsp;tells&nbsp;the&nbsp;truth</h3>
59
+
60
+ <p align="center">Work time counts only while Claude actually works, and stops when its session closes. See what waits for you, streaks, cycle time, when work happens and what Claude&nbsp;cost&nbsp;you.</p>
61
+
62
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/dashboard.png" alt="The dashboard: tasks waiting for you, week totals and activity charts" width="88%" /></p>
63
+
64
+ <br/>
65
+ <h3 align="center">📱 Your board on&nbsp;your&nbsp;phone</h3>
66
+
67
+ <p align="center"><b>Settings → Wi-Fi</b> shows a QR code. Scan it and the board, the chats and Claude are in your hand, over your own network, with no account and no cloud. Install it as an app, or switch&nbsp;the&nbsp;door&nbsp;off&nbsp;again.</p>
68
+
69
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/phone.png" alt="The board and a task chat on a phone" width="70%" /></p>
70
+
71
+ <br/>
72
+ <h3 align="center">🩺 Keep Claude's instructions&nbsp;healthy</h3>
73
+
74
+ <p align="center">Switch on <b>Settings → Health</b> and the board measures every CLAUDE.md against the norms of Claude Code and shows which projects load more than Claude will follow, and what to&nbsp;move&nbsp;into&nbsp;rules.</p>
75
+
76
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/health.png" alt="Health lists each project with the size of its CLAUDE.md and the norm it breaks" width="88%" /></p>
77
+
78
+ <br/>
79
+ <h3 align="center">🔌 Connected in&nbsp;one&nbsp;click</h3>
80
+
81
+ <p align="center"><b>Settings → Claude Code</b> shows what the board needs and writes it after showing you exactly what will change: the status line for your rate limits, the hook for answers from the card, the skill that teaches Claude&nbsp;the&nbsp;workflow.</p>
82
+
83
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/setup.png" alt="Settings for Claude Code: what shows on the board, how Claude starts and what it may do" width="88%" /></p>
84
+
85
+ <br/>
86
+ ### Also in the box
87
+
88
+ - **Rate limits in the sidebar**: the 5-hour and weekly limits of your subscription, and what each task spent in tokens.
89
+ - **GitHub mirror**: every task an issue, every project a GitHub Project, one way and in the background. Off until you set it.
90
+ - **Backups**: a daily copy, a copy before every migration, and any of them opens as a second board next to the live one.
91
+ - **Manual tasks**: the `noclaude` label keeps your own work out of Claude's metrics, and Claude never takes it.
92
+ - **Linked tasks, subtasks, checklists, pictures in comments, search, hotkeys**, all readable by Claude through `kb`.
93
+ - **English and Russian**, following your system language.
94
+ - **An HTTP API and an event stream** for your own bots and stats: [docs/API.md](docs/API.md).
95
+
96
+ ---
12
97
 
13
98
  ## How you use it
14
99
 
@@ -232,7 +317,7 @@ Your data is never touched by updates: `data/` is gitignored and schema migratio
232
317
 
233
318
  **Rollback:** stop the server, restore the latest file from `data/backups/` (daily) or `data/backups/pre-migrate/` over `data/kanban.db`, then `git checkout <previous tag>` and start again.
234
319
 
235
- Releases live in `main` (stable, tagged); day-to-day development happens in `dev`.
320
+ Releases live in `main` (tagged); day-to-day development happens in `dev`. A beta goes out the same way as a release, so `latest` and the update button bring it too. To go back: `npm install -g local-kanban@1.16.0`.
236
321
 
237
322
  ## Removing the board
238
323
 
package/README.ru.md CHANGED
@@ -1,14 +1,99 @@
1
- # local-kanban
1
+ <h1 align="center">
2
+ <a href="https://www.npmjs.com/package/local-kanban"><img src="public/icon-192.png" alt="local-kanban" width="72" valign="middle" /></a> local-kanban
3
+ <br>
4
+ Канбан-доска, с которой работает Claude Code
5
+ </h1>
2
6
 
3
- [![tests](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml/badge.svg)](https://github.com/lamaohub/local-kanban/actions/workflows/test.yml)
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/local-kanban"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/npm/local-kanban.svg?variant=secondary&mode=light" alt="Версия в npm" /></picture></a>
9
+ <a href="https://github.com/lamaohub/local-kanban/releases/latest"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/release/lamaohub/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/github/release/lamaohub/local-kanban.svg?variant=secondary&mode=light" alt="Последний релиз" /></picture></a>
10
+ <a href="LICENSE"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/license/lamaohub/local-kanban.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/github/license/lamaohub/local-kanban.svg?variant=secondary&mode=light" alt="Лицензия" /></picture></a>
11
+ <a href="#требования"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/platforms-macOS%20%C2%B7%20Linux.svg?variant=secondary&mode=dark" /><img src="https://shieldcn.dev/badge/platforms-macOS%20%C2%B7%20Linux.svg?variant=secondary&mode=light" alt="Работает на macOS и Linux" /></picture></a>
12
+ </p>
4
13
 
5
- Локальная канбан-доска для работы **в паре с Claude Code**: человек планирует и принимает задачи, Claude берёт их из очереди, делает и отчитывается — через токен-экономный CLI `kb`. Опционально зеркалит всё в GitHub (issues + Projects v2).
14
+ <h3 align="center"><a href="#установка"><ins>Установить</ins></a> &nbsp;·&nbsp; <a href="#как-этим-пользоваться">Как это работает</a> &nbsp;·&nbsp; <a href="CHANGELOG.md">Что нового</a> &nbsp;·&nbsp; <a href="README.md">English</a></h3>
6
15
 
7
- English version — [README.md](README.md).
16
+ <p align="center">
17
+ <img width="960" src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/board.gif" alt="Claude сам двигает карточки по доске: Сделать, Подготовка, Делаю, Выкатываю, Проверяю" />
18
+ </p>
8
19
 
9
- ![Доска](docs/screenshot-board.png)
20
+ Вы планируете и принимаете работу, Claude берёт её из очереди, делает и отчитывается. Всё лежит в одном файле SQLite на вашем компьютере и управляется через `kb` — CLI, который тратит мало токенов Claude. GitHub — необязательное зеркало: пока вы его не включите, ничего с компьютера не уходит.
10
21
 
11
- ![Дашборд](docs/screenshot-dashboard.png)
22
+ > [!NOTE]
23
+ > **2.0 в бете, и именно её ставит `npm install -g local-kanban`.** Она каждый день работает на настоящих проектах. Доска обновляется на месте и сохраняет данные; остаться на последней 1.x — `npm install -g local-kanban@1.16.0`. Что-то сломалось? [Заведите issue](https://github.com/lamaohub/local-kanban/issues).
24
+
25
+ ## Быстрый старт
26
+
27
+ ```bash
28
+ npm install -g local-kanban
29
+ local-kanban # мастер настройки: хватит Enter-Enter-Enter
30
+ local-kanban start # доска на http://localhost:3100
31
+ ```
32
+
33
+ Нужен Node.js 22 или новее на macOS или Linux. Подробности — в разделе [Установка](#установка).
34
+
35
+ ## Возможности
36
+
37
+ <h3 align="center">▶️ Нажали play — Claude работает</h3>
38
+
39
+ <p align="center">Одна кнопка на колонке <b>Сделать</b> отдаёт её Claude Code прямо внутри доски: окна не открываются, промпт вставлять не нужно. Claude берёт каждую задачу, сам двигает карточку, коммитит, выкатывает, если у проекта есть скилл деплоя, и останавливается на <b>Проверяю</b>. Готово — всегда&nbsp;ваше&nbsp;слово.</p>
40
+
41
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/claude-now.png" alt="Три сессии Claude работают рядом в разделе Claude now, у каждой свой чат" width="100%" /></p>
42
+
43
+ <br/>
44
+ <h3 align="center">💬 Разговор с Claude прямо&nbsp;в&nbsp;карточке</h3>
45
+
46
+ <p align="center">У каждой задачи свой чат: что сказал Claude, свёрнутые шаги, изменённые файлы и сколько он уже молчит. Там же можно ответить на вопросы и разрешения, отправить картинку, поставить следующие слова в очередь, пока он работает, или открыть&nbsp;его&nbsp;терминал.</p>
47
+
48
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/card-chat.png" alt="Карточка задачи с чатом Claude: шаги, изменённые файлы и поле ответа" width="100%" /></p>
49
+
50
+ <br/>
51
+ <h3 align="center">🔎 Работу проверяет свежий&nbsp;Claude</h3>
52
+
53
+ <p align="center">Дайте колонке <b>Проверяю</b> накопиться и нажмите одну кнопку. <b>Claude review</b> заводит задачу-проверку и запускает Claude, который этот код не писал: он тестирует каждую задачу, пробует её сломать, выжившее переводит в Готово и оставляет отчёт. <b>Автоматизации</b> запускают это сами, когда&nbsp;проект&nbsp;затих.</p>
54
+
55
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/claude-review.png" alt="Claude review показывает задачи в проверке по проектам и кнопку Check in Claude" width="88%" /></p>
56
+
57
+ <br/>
58
+ <h3 align="center">📊 Дашборд, которому&nbsp;можно&nbsp;верить</h3>
59
+
60
+ <p align="center">Время работы считается, только пока Claude действительно работает, и стоит, когда сессия закрыта. Видно, что ждёт вас, серии, цикл задачи, когда идёт работа и сколько&nbsp;стоил&nbsp;Claude.</p>
61
+
62
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/dashboard.png" alt="Дашборд: что ждёт вас, итоги недели и графики активности" width="88%" /></p>
63
+
64
+ <br/>
65
+ <h3 align="center">📱 Доска у вас&nbsp;в&nbsp;телефоне</h3>
66
+
67
+ <p align="center"><b>Настройки → Wi-Fi</b> показывают QR-код. Отсканируйте его, и доска, чаты и Claude у вас в руке — по вашей сети, без аккаунтов и облака. Можно поставить как приложение, а дверь&nbsp;в&nbsp;любой&nbsp;момент&nbsp;закрыть.</p>
68
+
69
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/phone.png" alt="Доска и чат задачи на телефоне" width="70%" /></p>
70
+
71
+ <br/>
72
+ <h3 align="center">🩺 Здоровье инструкций&nbsp;Claude</h3>
73
+
74
+ <p align="center">Включите <b>Настройки → Здоровье</b>, и доска сверит каждый CLAUDE.md с нормами Claude Code и покажет, в каких проектах инструкций больше, чем Claude выполнит, и что вынести&nbsp;в&nbsp;правила.</p>
75
+
76
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/health.png" alt="Здоровье: проекты с размером CLAUDE.md и нарушенной нормой" width="88%" /></p>
77
+
78
+ <br/>
79
+ <h3 align="center">🔌 Подключение в&nbsp;один&nbsp;клик</h3>
80
+
81
+ <p align="center"><b>Настройки → Claude Code</b> показывают, чего доске не хватает, и записывают это, сперва показав, что именно изменится: строку состояния для лимитов, хук для ответов из карточки, скилл с рабочим&nbsp;циклом&nbsp;для&nbsp;Claude.</p>
82
+
83
+ <p align="center"><img src="https://raw.githubusercontent.com/lamaohub/local-kanban/main/docs/readme/setup.png" alt="Настройки Claude Code: что показывать на доске, как запускать Claude и что ему можно" width="88%" /></p>
84
+
85
+ <br/>
86
+ ### И ещё
87
+
88
+ - **Лимиты в сайдбаре**: пятичасовой и недельный лимиты подписки и расход токенов по каждой задаче.
89
+ - **Зеркало на GitHub**: задача — issue, проект — GitHub Project, в одну сторону и в фоне. Выключено, пока не настроите.
90
+ - **Бэкапы**: копия каждый день, копия перед каждой миграцией, и любую можно открыть второй доской рядом с живой.
91
+ - **Ручные задачи**: метка `noclaude` не пускает вашу работу в метрики Claude, и Claude такую задачу не возьмёт.
92
+ - **Связи, подзадачи, чек-листы, картинки в комментариях, поиск, горячие клавиши** — и всё это Claude читает через `kb`.
93
+ - **Русский и английский** — по языку системы.
94
+ - **HTTP API и поток событий** для своих ботов и статистики: [docs/API.md](docs/API.md).
95
+
96
+ ---
12
97
 
13
98
  ## Как этим пользоваться
14
99
 
@@ -216,7 +301,7 @@ npm run update # git pull + npm install + pm2 restart (если примени
216
301
 
217
302
  **Откат:** останови сервер, восстанови свежий файл из `data/backups/` (суточные) или `data/backups/pre-migrate/` поверх `data/kanban.db`, затем `git checkout <предыдущий тег>` и запусти снова.
218
303
 
219
- Релизы живут в `main` (стабильное, с тегами); ежедневная разработка — в `dev`.
304
+ Релизы живут в `main` (с тегами); ежедневная разработка — в `dev`. Бета выходит так же, как релиз, поэтому `latest` и кнопка обновления приносят и её. Вернуться назад: `npm install -g local-kanban@1.16.0`.
220
305
 
221
306
  ## Как убрать доску
222
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.16.0",
3
+ "version": "2.0.0-beta.1",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
package/public/js/chat.js CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { api, esc, ic, seg, tr } from './core.js';
3
- import { SHORT_MONTHS, copyText, resumeOnBoard } from './sse.js';
3
+ import { SHORT_MONTHS, copyText, resumeOnBoard, usageFmt } from './sse.js';
4
4
  import { matchKey, plural, setClaudeView } from './settings.js';
5
5
  import { refusalLine } from './unsaved.js';
6
6
  import { drawerIsFull, openDrawerFull, openLightbox, setDrawerFull, uploadAttachment } from './drawer.js';
@@ -10,6 +10,8 @@ import { buildSelect } from './board.js';
10
10
  import { SHELL_LANGS, chatText } from './chat-md.js';
11
11
  import { askBlock, isSending, plainLabel, questionAsk, renderAsks } from './asks.js';
12
12
  import { intoField, leftoverWords } from './ask-rules.js';
13
+ import { paceStep, paceWords, stalled } from './pace-rules.js';
14
+ import { tokensPhrase } from './usage-rules.js';
13
15
 
14
16
  const TURNS_STEP = 15;
15
17
  const STEP_KIND = {
@@ -81,7 +83,7 @@ function clockOf(iso) {
81
83
  }
82
84
 
83
85
  function stepRow(it, working = false) {
84
- const tail = it.running ? '' : [dur(it.ms), it.error ? '' : (it.ms !== undefined ? '✓' : '')].filter(Boolean).join(' ');
86
+ const tail = it.running ? '' : [it.wrote ? timesWords(it.wrote, it.ms) : dur(it.ms), it.error ? '' : (it.ms !== undefined ? '✓' : '')].filter(Boolean).join(' ');
85
87
  const steps = it.helper ? `<span class="chat-step-n">${it.helper.steps} ${esc(plural(it.helper.steps, 'step', 'steps'))}</span>` : '';
86
88
  const since = it.running ? clockOf(it.at) : '';
87
89
  const bg = it.running ? `<span class="chat-bg">${working ? '<span class="chat-pulse"></span>' : ''}${esc(since ? `${tr('in the background since')} ${since}` : tr('in the background'))}</span>` : '';
@@ -89,7 +91,7 @@ function stepRow(it, working = false) {
89
91
  : `<span class="chat-step-t">${esc(it.text)}</span>`;
90
92
  return `<div class="chat-step${it.error ? ' err' : ''}${it.running ? ' running' : ''}" title="${esc(it.text)}"><span class="chat-step-k">${esc(tr(STEP_KIND[it.tool] || 'Tool'))}</span>`
91
93
  + what
92
- + `<span class="chat-step-d">${steps}${bg}${it.error ? `<span class="chat-x" title="${esc(tr('failed'))}">✗</span>` : ''}${esc(tail)}</span></div>`
94
+ + `<span class="chat-step-d"${it.wrote ? ` title="${esc(tr(TIMES_TITLE))}"` : ''}>${steps}${bg}${it.error ? `<span class="chat-x" title="${esc(tr('failed'))}">✗</span>` : ''}${esc(tail)}</span></div>`
93
95
  + (it.helper?.now ? `<div class="chat-answer chat-now">↳ ${esc(tr(STEP_KIND[it.helper.now.tool] || 'Tool'))} · ${esc(it.helper.now.text)}</div>` : '')
94
96
  + (it.answer ? `<div class="chat-answer">↳ ${esc(it.answer)}</div>` : '');
95
97
  }
@@ -174,6 +176,9 @@ export function sinceWords(iso, now = Date.now()) {
174
176
  if (s < 60) return `${s} ${tr('s')}`;
175
177
  return s < 3600 ? `${Math.floor(s / 60)} ${tr('min')} ${s % 60} ${tr('s')}` : `${Math.floor(s / 3600)} ${tr('h')} ${Math.floor(s / 60) % 60} ${tr('min')}`;
176
178
  }
179
+ const TIMES_TITLE = 'How long Claude thought and wrote this, and how long it ran';
180
+ const RUN_TIMES_MIN_MS = 10000; // a run written in moments says nothing of it: the line stays short
181
+ const timesWords = (wrote, ran) => [`${tr('wrote for')} ${sinceWords(0, wrote)}`, ran >= 1000 ? `${tr('ran for')} ${sinceWords(0, ran)}` : ''].filter(Boolean).join(' · ');
177
182
  const baseName = (f) => String(f).slice(String(f).lastIndexOf('/') + 1);
178
183
  function runHTML(steps, id, open, { working, now = null, pic = null }) {
179
184
  const failed = steps.filter((s) => s.error).length;
@@ -191,7 +196,10 @@ function runHTML(steps, id, open, { working, now = null, pic = null }) {
191
196
  // a picture Claude read shows at the line's end, as in the app, and opens whole on a click (not the fold: `data-pic`)
192
197
  const seen = pic && steps.findLast((s) => s.pic);
193
198
  const thumb = seen ? `<img class="chat-run-pic" data-pic="${esc(seen.pic)}" src="${esc(pic(seen.pic))}" alt="" title="${esc(tr('Open the picture'))}" loading="lazy" decoding="async">` : '';
194
- return `<button type="button" class="chat-run${open ? ' open' : ''}${now ? ' now' : ''}" data-fold="${esc(id)}" aria-expanded="${open}">${head}`
199
+ const wrote = steps.reduce((x, s) => x + (s.wrote || 0), 0);
200
+ const ran = steps.reduce((x, s) => x + (s.running ? 0 : s.ms || 0), 0);
201
+ const times = !now && wrote >= RUN_TIMES_MIN_MS ? `<span class="chat-run-times" title="${esc(tr(TIMES_TITLE))}">${esc(timesWords(wrote, ran))}</span>` : '';
202
+ return `<button type="button" class="chat-run${open ? ' open' : ''}${now ? ' now' : ''}" data-fold="${esc(id)}" aria-expanded="${open}">${head}${times}`
195
203
  + `${failed ? `<span class="chat-x">✗ ${failed}</span>` : ''}${ic('chevron', 11)}${thumb}</button>`
196
204
  + (open ? `<div class="chat-unfold chat-runbox" data-of="${esc(id)}">${steps.map((s) => stepRow(s, working)).join('')}</div>` : '');
197
205
  }
@@ -311,24 +319,38 @@ const AWAY_LOOK_MS = 5000; // a transcript not written yet (a Claude just starte
311
319
  const LIT_LOOK_MS = 5000;
312
320
  const SENT_LOOK_MS = 1500;
313
321
 
314
- export function workingLine(d) {
322
+ /** A step of the turn going on with no answer yet, or a helper at work: what Claude waits on — it writes nothing then. */
323
+ const stepInHand = (d) => {
324
+ const turn = d.turns?.at(-1);
325
+ return Boolean(turn?.open && turn.items.some((it) => it.kind === 'tool' && it.ms === undefined && !it.running)) || running(d.turns).helpers > 0;
326
+ };
327
+ /** The moment the last line of a turn tells of: the transcript is silent from there on. 0 when there is none. */
328
+ function lastMoment(turn) {
329
+ const moments = [new Date(turn.at).getTime(), ...turn.items.map((it) => new Date(it.at).getTime() + (it.ms || 0))];
330
+ return Math.max(...moments.filter(Number.isFinite), 0);
331
+ }
332
+
333
+ export function workingLine(d, stuckSince = 0) {
315
334
  if (!(d.working || (d.send && d.state === 'busy'))) return '';
316
335
  const { helpers, commands } = running(d.turns);
317
336
  const turn = d.turns?.at(-1);
318
337
  const turnOn = Boolean(turn?.open);
319
338
  // the card's head) — it waits for the person; «Claude works» there had the owner ask it, and it said nothing ran
320
339
  const left = !turnOn && d.turns?.length && !helpers && commands && d.own === false;
321
- const words = tr(turnOn || !d.turns?.length ? 'Claude is working…' : left ? 'Waits for you · commands in the background' : 'Claude works in the background…')
340
+ const atWork = d.pace && !stuckSince ? paceWords(d.pace, stepInHand(d)) : 'Claude is working…';
341
+ const words = tr(turnOn || !d.turns?.length ? atWork : left ? 'Waits for you · commands in the background' : 'Claude works in the background…')
322
342
  + (helpers ? ` · ${tr('helpers at work:')} ${helpers}` : '');
323
343
  // a step with no answer yet, not one handed to the background — the one Claude waits on now
324
344
  const now = turnOn ? turn.items.at(-1) : null;
325
345
  const step = now?.kind === 'tool' && now.ms === undefined && !now.running
326
346
  ? `<span class="chat-working-now" title="${esc(now.text)}">${esc(tr(STEP_KIND[now.tool] || 'Tool'))} · ${esc(now.text)}</span>` : '';
327
- return `<div class="chat-working"><span class="chat-pulse"></span><span class="chat-working-t">${esc(words)}</span>${step}</div>`;
347
+ const since = stuckSince || (turnOn ? lastMoment(turn) : 0);
348
+ const meter = since ? `<span class="chat-working-m">${stuckSince ? `${esc(tr('no answer for'))} ` : ''}<span class="chat-run-time" data-since="${esc(new Date(since).toISOString())}"></span><span class="chat-working-n"></span></span>` : '';
349
+ return `<div class="chat-working${stuckSince ? ' stalled' : ''}"><span class="chat-pulse"></span><span class="chat-working-t">${esc(words)}</span>${meter}${step}</div>`;
328
350
  }
329
351
 
330
- export function liveHTML(d) {
331
- const lit = workingLine(d);
352
+ export function liveHTML(d, stuckSince = 0) {
353
+ const lit = workingLine(d, stuckSince);
332
354
  if (!lit) return '';
333
355
  const going = (d.turns || []).flatMap((t) => t.items.filter((it) => it.kind === 'tool' && it.running));
334
356
  return `${going.length ? `<div class="chat-going">${going.map((it) => stepRow(it, true)).join('')}</div>` : ''}${lit}`;
@@ -552,6 +574,23 @@ export function mountChat(box, { key = null, session = null, scroller = null, fu
552
574
  };
553
575
  };
554
576
 
577
+ function liveNow(d) {
578
+ if (!d.pace) { c.paceMark = null; return liveHTML(d); }
579
+ const at = Date.now();
580
+ c.paceMark = paceStep(c.paceMark, JSON.stringify([d.session, d.pace, d.turns?.at(-1)?.items.length]), at);
581
+ return liveHTML(d, stalled(d.pace, stepInHand(d), c.paceMark, at) ? c.paceMark.at : 0);
582
+ }
583
+ function showLive(lit, d) {
584
+ // drawn only when it changed: the pulse would start anew on every look, five seconds apart
585
+ if (liveEl.dataset.html !== lit) { liveEl.dataset.html = lit; liveEl.innerHTML = lit; }
586
+ const count = liveEl.querySelector?.('.chat-working-n');
587
+ if (!count) return;
588
+ // a count that stopped says nothing new, and a narrow column has no room for it beside the words of a silent Claude
589
+ count.textContent = d.pace?.tokens && !liveEl.querySelector('.stalled') ? ` · ${tokensPhrase(d.pace.tokens, usageFmt)}` : '';
590
+ tickRuns();
591
+ if (!runClock) runClock = setInterval(tickRuns, 1000);
592
+ }
593
+
555
594
  function paint() {
556
595
  notePlace();
557
596
  const d = c.data;
@@ -590,7 +629,7 @@ export function mountChat(box, { key = null, session = null, scroller = null, fu
590
629
  const keepFrom = sc.scrollHeight - sc.scrollTop;
591
630
  const keepTop = sc.scrollTop;
592
631
  const anchor = atEnd ? null : seenTurn();
593
- const lit = liveHTML(d);
632
+ const lit = liveNow(d);
594
633
  const sending = sentHTML(d);
595
634
  if (!d.session) {
596
635
  log.innerHTML = `<div class="chat-empty muted">${esc(tr('Claude has not worked on this task yet'))}</div>`;
@@ -609,8 +648,7 @@ export function mountChat(box, { key = null, session = null, scroller = null, fu
609
648
  pic: (id) => `/api/tasks/${seg(c.key)}/chat/picture/${seg(id)}?session=${encodeURIComponent(c.session)}` })).join('')
610
649
  + sending;
611
650
  }
612
- // drawn only when it changed: the pulse would start anew on every look, five seconds apart
613
- if (liveEl.dataset.html !== lit) { liveEl.dataset.html = lit; liveEl.innerHTML = lit; }
651
+ showLive(lit, d);
614
652
  if (!runClock && log.querySelector('.chat-run-time')) runClock = setInterval(tickRuns, 1000);
615
653
  // stopped (Esc) or ended its turn rings no event once its last line is written: the light stayed on for good
616
654
  clearTimeout(c.helperTimer);
@@ -916,6 +954,8 @@ export function mountChat(box, { key = null, session = null, scroller = null, fu
916
954
  if (key !== c.key || !mounted.has(c)) return; // another task meanwhile, or gone
917
955
  c.shownAsked = asked; // what the chat on screen was asked for: a pick that fails goes back to it
918
956
  if (r.same) {
957
+ c.data.pace = r.pace;
958
+ showLive(liveNow(c.data), c.data);
919
959
  if (workingLine(c.data)) { clearTimeout(c.helperTimer); c.helperTimer = setTimeout(refresh, LIT_LOOK_MS); }
920
960
  return;
921
961
  }
package/public/js/core.js CHANGED
@@ -973,6 +973,7 @@ const I18N_RU = {
973
973
  'a rule without paths': 'правило без paths',
974
974
  'auto memory — the part that loads': 'автопамять — та часть, что грузится',
975
975
  'yours, in every project': 'твой, в каждом проекте',
976
+ 'a rule with paths: — loads when Claude works with its files': 'правило с paths: — грузится, когда Claude работает с его файлами',
976
977
  'loads when Claude works in that folder': 'грузится, когда Claude работает в этой папке',
977
978
  'over 4 MB — Claude Code skips it': 'больше 4 МБ — Claude Code его пропускает',
978
979
  'no CLAUDE.md of its own': 'своего CLAUDE.md нет',
@@ -1638,6 +1639,12 @@ const I18N_RU = {
1638
1639
  'Claude has not worked on this task yet': 'Claude ещё не работал над этой задачей',
1639
1640
  'No messages yet': 'Сообщений пока нет',
1640
1641
  'Claude is working…': 'Claude работает…',
1642
+ 'Claude is thinking…': 'Claude думает…',
1643
+ 'Claude is writing…': 'Claude пишет…',
1644
+ 'no answer for': 'нет ответа уже',
1645
+ 'wrote for': 'писал',
1646
+ 'ran for': 'выполнял',
1647
+ 'How long Claude thought and wrote this, and how long it ran': 'Сколько Claude думал и писал это и сколько оно выполнялось',
1641
1648
  'Claude works in the background…': 'Claude работает в фоне…',
1642
1649
  'helpers at work:': 'помощников в работе:',
1643
1650
  'in the background': 'в фоне',
@@ -28,6 +28,7 @@ function kindWord(f) {
28
28
  return tr({
29
29
  main: 'the project\'s own', parent: 'from a folder above', local: 'personal, CLAUDE.local.md', rule: 'a rule without paths',
30
30
  memory: 'auto memory — the part that loads', user: 'yours, in every project', nested: 'loads when Claude works in that folder',
31
+ scoped: 'a rule with paths: — loads when Claude works with its files',
31
32
  }[f.kind] || f.kind);
32
33
  }
33
34
  function fileHTML(f, norms) {
@@ -0,0 +1,18 @@
1
+
2
+ /** How long nothing may change before the light says there is no answer. */
3
+ export const STALL_MS = 60_000;
4
+
5
+ /** The light's words. `inHand` — a step or a helper Claude waits on: the counter stands still by right then. */
6
+ export function paceWords(pace, inHand) {
7
+ if (inHand || !pace || pace.compacting) return 'Claude is working…';
8
+ if (pace.thinking) return 'Claude is thinking…';
9
+ return pace.tokens ? 'Claude is writing…' : 'Claude is working…';
10
+ }
11
+
12
+ /** The moment the spinner last said something new: `mark` is kept while `sig` — what it says now — stays the same. */
13
+ export const paceStep = (mark, sig, now) => (mark?.sig === sig ? mark : { sig, at: now });
14
+
15
+ /** Whether Claude stopped answering: its counter stood still for STALL_MS with nothing to wait on. Only a counter says
16
+ so: Claude Code leaves it out of a line that does not fit the window (a spinner with none is a Claude writing a long
17
+ script as often as not), and it shows none while Claude thinks or compacts. */
18
+ export const stalled = (pace, inHand, mark, now) => Boolean(pace?.tokens && !pace.thinking && !pace.compacting && !inHand && mark && now - mark.at >= STALL_MS);
package/public/style.css CHANGED
@@ -1591,6 +1591,8 @@ html[data-design="v3"] .now-col.col-full { /* v3: .panel rounds and frames it ot
1591
1591
  .chat-run:hover { color: var(--text); }
1592
1592
  .chat-run-t { min-width: 0; overflow-wrap: anywhere; } /* wraps: cut with «…», a long line hid what Claude did */
1593
1593
  .chat-run-file { color: var(--field-text); }
1594
+ .chat-run:has(> .chat-run-times) > .chat-run-t { flex: none; max-width: calc(100% - 20px); } /* the times give way first: a pixel taken off the words wrapped them */
1595
+ .chat-run-times { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11.5px; opacity: .8; font-variant-numeric: tabular-nums; }
1594
1596
  .chat-run-time { flex: none; white-space: nowrap; font-variant-numeric: tabular-nums; }
1595
1597
  .chat-run > svg { flex: none; transform: rotate(-90deg); transition: transform .15s ease; }
1596
1598
  .chat-run.open > svg { transform: none; }
@@ -1637,12 +1639,15 @@ html[data-design="v3"] .now-col.col-full { /* v3: .panel rounds and frames it ot
1637
1639
  /* a narrow column: the words give way with an ellipsis, the dot never does (a squeezed dot went out of sight) */
1638
1640
  .chat-working .chat-pulse { flex: none; }
1639
1641
  .chat-working-t { flex: 0 1000 auto; min-width: min(7.5em, 40%); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
1640
- .chat-working-now { flex: 1 1 auto; min-width: 4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--field-text); }
1642
+ .chat-working-m { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
1643
+ .chat-working-now { flex: 1 100 auto; min-width: 4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--field-text); }
1641
1644
  .chat-live { display: flex; flex-direction: column; gap: 6px; }
1642
1645
  .chat-live:empty { display: none; }
1643
1646
  .chat-going { display: flex; flex-direction: column; gap: 2px; max-height: 30vh; overflow-y: auto; }
1644
- .chat-pulse { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--doing); }
1645
- .chat-pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--doing); animation: chat-pulse 1.4s ease-out infinite; }
1647
+ .chat-pulse { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
1648
+ .chat-pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--ok); animation: chat-pulse 1.4s ease-out infinite; }
1649
+ .chat-working.stalled .chat-pulse, .chat-working.stalled .chat-pulse::after { background: var(--doing); }
1650
+ .chat-working.stalled .chat-pulse::after { animation: none; }
1646
1651
  @keyframes chat-pulse { from { transform: scale(1); opacity: .6; } to { transform: scale(2.6); opacity: 0; } }
1647
1652
  .chat-empty { padding: 14px 4px; font-size: 12.5px; }
1648
1653
  .chat-earlier { align-self: center; }
package/src/chat-feed.js CHANGED
@@ -376,14 +376,26 @@ export async function saidCommand(path, code) {
376
376
  return null;
377
377
  }
378
378
 
379
+ const WROTE_MIN_MS = 3000;
380
+ const WROTE_MAX_MS = 30 * 60_000;
379
381
  /** The turns of a chat from its items, oldest first: [{at, user, images?, items, ms, open, compact?}]. A turn begins
380
382
  with the person's words (or a compacted conversation); `ms` — how long Claude worked, from Claude Code's own line;
381
383
  `open` — no such line yet: Claude is on it, or it was stopped. Steps get their outcome: `ms`, `error`, and the answer
382
- to a question of Claude's. */
384
+ to a question of Claude's; `wrote` — how long Claude took to write the step. */
383
385
  export function chatTurns(items) {
384
386
  const calls = new Map();
385
387
  const byTask = new Map(); // id of background work → its call
386
- for (const it of items) if (it.kind === 'tool') { calls.set(it.id, it); if (it.bg) it.running = true; }
388
+ let before = null;
389
+ for (const it of items) {
390
+ if (it.kind === 'tool') {
391
+ calls.set(it.id, it);
392
+ if (it.bg) it.running = true;
393
+ // Claude thinking and writing it. Moments are not told (a second step of one answer), a pause of hours is no work
394
+ const took = before === null ? 0 : it.at - before;
395
+ if (took >= WROTE_MIN_MS && took <= WROTE_MAX_MS) it.wrote = took;
396
+ }
397
+ before = it.at;
398
+ }
387
399
  for (const it of items) {
388
400
  const call = (it.kind === 'result' || it.kind === 'notified') && calls.get(it.id);
389
401
  if (!call) continue;
package/src/health.js CHANGED
@@ -66,16 +66,18 @@ async function measure(path, kind, seen, out, { from = null, hop = 0 } = {}) {
66
66
  }
67
67
  }
68
68
 
69
- async function rulesIn(dir, seen, out) {
69
+ /** The rules of `dir` that load at the start; with `scoped` (the project's folder), those with `paths:` go there. */
70
+ async function rulesIn(dir, seen, out, scoped = null) {
70
71
  let entries;
71
72
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
72
73
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
73
74
  const p = join(dir, e.name);
74
- if (e.isDirectory()) { await rulesIn(p, seen, out); continue; }
75
+ if (e.isDirectory()) { await rulesIn(p, seen, out, scoped); continue; }
75
76
  if (!e.name.endsWith('.md')) continue;
76
77
  const got = await readMemory(p);
77
- if (!got || ruleIsScoped(got.text)) continue;
78
- await measure(p, 'rule', seen, out);
78
+ if (!got) continue;
79
+ if (!ruleIsScoped(got.text)) await measure(p, 'rule', seen, out);
80
+ else if (scoped) scoped.list.push({ path: relative(scoped.top, p), kind: 'scoped', chars: got.text.length, lines: lineCount(got.text) });
79
81
  }
80
82
  }
81
83
 
@@ -154,13 +156,14 @@ export async function projectHealth(dir, skip = new Set()) {
154
156
  for (const f of files.slice(before)) if (f.kind === 'main') f.name = relative(top, expandPath(f.path));
155
157
  await measure(join(d, 'CLAUDE.local.md'), 'local', seen, files);
156
158
  }
157
- await rulesIn(join(top, '.claude', 'rules'), seen, files);
159
+ const scoped = { top, list: [] };
160
+ await rulesIn(join(top, '.claude', 'rules'), seen, files, scoped);
158
161
  await memoryOf(top, files);
159
162
  const main = files.find((f) => f.kind === 'main') || null;
160
163
  return {
161
164
  main: main && { path: main.name, chars: main.chars, lines: main.lines },
162
165
  files, chars: sum(files, 'chars'), lines: sum(files, 'lines'),
163
- nested: await nestedOf(top, seen),
166
+ nested: [...await nestedOf(top, seen), ...scoped.list].sort((a, b) => b.chars - a.chars),
164
167
  };
165
168
  }
166
169
 
package/src/phantom.js CHANGED
@@ -449,7 +449,10 @@ export function noteLook(sid) { looked.set(sid, Date.now()); }
449
449
  const lookedLately = (sid) => Date.now() - (looked.get(sid) ?? -Infinity) < lookHold;
450
450
 
451
451
  export const ASK_FRESH_MS = 3000;
452
+ export const CHAT_COLS = 72;
452
453
  const asked = new Map(); // sid → Map(width → ms of its last ask)
454
+ /** Whether a terminal of `sid` is on a screen: a viewer asked for its width just now. */
455
+ export const watched = (sid, now = Date.now()) => [...(asked.get(sid)?.values() ?? [])].some((at) => now - at <= ASK_FRESH_MS);
453
456
  /** The width to lay the window of `sid` out to, now that a viewer asked for `cols`. */
454
457
  export function widthFor(sid, cols, now = Date.now()) {
455
458
  let widths = asked.get(sid);
@@ -576,6 +579,29 @@ export function turnOverOnScreen(screen) {
576
579
  if (!/^\s{2,}\S/.test(last)) return false; // only an indented line continues the one above it
577
580
  return TURN_OVER.test(`${above.at(-2) ?? ''} ${last.trim()}`.replace(/\s+/g, ' '));
578
581
  }
582
+ const SPINNER = /^\s*[·✢✳✶✻✽*]\s.*…\s*\((?:\d+h ?)?(?:\d+m ?)?\d+s(?: · ([^)]*))?/;
583
+ const SPINNER_LINES = 14;
584
+ const SPINNER_TOKENS = /[↓↑]\s*([\d.]+)\s*([km]?)\s*tokens?/i;
585
+ /** What the spinner says: {tokens?, thinking?, compacting?} — null when there is none on the screen. */
586
+ export function paceOnScreen(screen) {
587
+ const lines = shownLines(screen);
588
+ const above = abovePromptBox(lines, 8);
589
+ if (above.length === lines.length) return null;
590
+ for (let i = above.length - 1; i >= Math.max(0, above.length - SPINNER_LINES); i--) {
591
+ const joined = `${above[i]} ${(above[i + 1] ?? '').trim()}`;
592
+ const m = SPINNER.exec(joined);
593
+ if (!m) continue;
594
+ const said = m[1] ?? '';
595
+ const n = SPINNER_TOKENS.exec(said);
596
+ return {
597
+ ...(n ? { tokens: Math.round(Number(n[1]) * { '': 1, k: 1e3, m: 1e6 }[n[2].toLowerCase()]) } : {}),
598
+ ...(/\bth(?:inking|ought)\b/.test(said) ? { thinking: true } : {}),
599
+ ...(/compacting/i.test(joined) ? { compacting: true } : {}),
600
+ };
601
+ }
602
+ return null;
603
+ }
604
+
579
605
  const LIMIT_HIT = /\b(?:usage|5-hour|weekly|session|opus|sonnet|plan) limit reached|\bhit your (?:[\w-]+ )?limit/i;
580
606
  // "resets 12:50am", "resets at 3pm", "resets Oct 9 at 10am", "resets Oct 9, 10am"
581
607
  const LIMIT_RESETS = /resets?(?: at)? ((?:[a-z]{3,9} \d{1,2},? (?:at )?)?\d{1,2}(?::\d{2})?\s?(?:am|pm)?)/i;
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { resolveTask, taskAttachmentPaths } from './tasks.js';
3
- import { REPLY_MAX, claudeAtPrompt, phantomScreen, phantomSend, taskTerminal } from '../phantom.js';
3
+ import { CHAT_COLS, REPLY_MAX, claudeAtPrompt, paceOnScreen, phantomResize, phantomScreen, phantomSend, taskTerminal, watched } from '../phantom.js';
4
4
  import { SESSION_ID_RE, taskSessions, transcriptNow } from '../claude-sessions.js';
5
5
  import { PICTURE_MAX, PICTURE_TYPES, SHOT_RE, TURNS_DEFAULT, TURNS_MAX, chatHash, chatPicture, chatShot, pictureKindOf, saidCommand, transcriptChat, watchChat } from '../chat-feed.js';
6
6
  import { createReadStream } from 'node:fs';
@@ -128,8 +128,11 @@ export default async function chatRoutes(app) {
128
128
  if (!working) chat.waiting = [];
129
129
  const body = { session: s.session_id, sessions: list, ...chat, queue: queued(s.session_id), send, state, working, own, ...(question ? { question } : {}), ...(choice ? { choice } : {}), ...(ended ? { ended } : {}) };
130
130
  const v = chatHash(body);
131
- if (seen === v) return { same: true, v };
132
- return { ...body, v };
131
+ const pace = state === 'busy' ? paceOnScreen((await phantomScreen(s.session_id, 0)) ?? '') : null;
132
+ if (state === 'busy' && CHAT_COLS > (term.cols ?? 0) + 2 && !watched(s.session_id)) await phantomResize(s.session_id, CHAT_COLS);
133
+ const live = pace ? { pace } : {};
134
+ if (seen === v) return { same: true, v, ...live };
135
+ return { ...body, ...live, v };
133
136
  });
134
137
 
135
138
  /** The type of a picture by its first bytes (PNG, JPEG, GIF, WebP), null for anything else. */
@@ -40,9 +40,20 @@ function selfPkg() {
40
40
  }
41
41
 
42
42
  export function isNewer(candidate, current) {
43
- const nums = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
44
- const [a, b] = [nums(candidate), nums(current)];
45
- for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
43
+ const parse = (v) => {
44
+ const [core, ...pre] = String(v).split('-');
45
+ return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre.length ? pre.join('-').split('.') : null };
46
+ };
47
+ const [a, b] = [parse(candidate), parse(current)];
48
+ for (let i = 0; i < 3; i++) if ((a.nums[i] || 0) !== (b.nums[i] || 0)) return (a.nums[i] || 0) > (b.nums[i] || 0);
49
+ if (!a.pre || !b.pre) return !a.pre && !!b.pre;
50
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
51
+ const [x, y] = [a.pre[i], b.pre[i]];
52
+ if (x === y) continue;
53
+ if (x === undefined || y === undefined) return y === undefined;
54
+ if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) > Number(y);
55
+ return x > y;
56
+ }
46
57
  return false;
47
58
  }
48
59