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 +24 -0
- package/README.md +92 -7
- package/README.ru.md +92 -7
- package/package.json +1 -1
- package/public/js/chat.js +52 -12
- package/public/js/core.js +7 -0
- package/public/js/health.js +1 -0
- package/public/js/pace-rules.js +18 -0
- package/public/style.css +8 -3
- package/src/chat-feed.js +14 -2
- package/src/health.js +9 -6
- package/src/phantom.js +26 -0
- package/src/routes/chat.js +6 -3
- package/src/routes/system.js +14 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
<h3 align="center"><a href="#install"><ins>Install</ins></a> · <a href="#how-you-use-it">How it works</a> · <a href="CHANGELOG.md">What's new</a> · <a href="README.ru.md">Русская версия</a></h3>
|
|
6
15
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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 your 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 the 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 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 the 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 project goes 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 tells the 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 cost 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 your 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 the door off 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 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 move into 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 one 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 the 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` (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
<h3 align="center"><a href="#установка"><ins>Установить</ins></a> · <a href="#как-этим-пользоваться">Как это работает</a> · <a href="CHANGELOG.md">Что нового</a> · <a href="README.md">English</a></h3>
|
|
6
15
|
|
|
7
|
-
|
|
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
|
-
|
|
20
|
+
Вы планируете и принимаете работу, Claude берёт её из очереди, делает и отчитывается. Всё лежит в одном файле SQLite на вашем компьютере и управляется через `kb` — CLI, который тратит мало токенов Claude. GitHub — необязательное зеркало: пока вы его не включите, ничего с компьютера не уходит.
|
|
10
21
|
|
|
11
|
-
!
|
|
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>. Готово — всегда ваше слово.</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 прямо в карточке</h3>
|
|
45
|
+
|
|
46
|
+
<p align="center">У каждой задачи свой чат: что сказал Claude, свёрнутые шаги, изменённые файлы и сколько он уже молчит. Там же можно ответить на вопросы и разрешения, отправить картинку, поставить следующие слова в очередь, пока он работает, или открыть его терминал.</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">🔎 Работу проверяет свежий Claude</h3>
|
|
52
|
+
|
|
53
|
+
<p align="center">Дайте колонке <b>Проверяю</b> накопиться и нажмите одну кнопку. <b>Claude review</b> заводит задачу-проверку и запускает Claude, который этот код не писал: он тестирует каждую задачу, пробует её сломать, выжившее переводит в Готово и оставляет отчёт. <b>Автоматизации</b> запускают это сами, когда проект затих.</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">📊 Дашборд, которому можно верить</h3>
|
|
59
|
+
|
|
60
|
+
<p align="center">Время работы считается, только пока Claude действительно работает, и стоит, когда сессия закрыта. Видно, что ждёт вас, серии, цикл задачи, когда идёт работа и сколько стоил 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">📱 Доска у вас в телефоне</h3>
|
|
66
|
+
|
|
67
|
+
<p align="center"><b>Настройки → Wi-Fi</b> показывают QR-код. Отсканируйте его, и доска, чаты и Claude у вас в руке — по вашей сети, без аккаунтов и облака. Можно поставить как приложение, а дверь в любой момент закрыть.</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">🩺 Здоровье инструкций Claude</h3>
|
|
73
|
+
|
|
74
|
+
<p align="center">Включите <b>Настройки → Здоровье</b>, и доска сверит каждый CLAUDE.md с нормами Claude Code и покажет, в каких проектах инструкций больше, чем Claude выполнит, и что вынести в правила.</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">🔌 Подключение в один клик</h3>
|
|
80
|
+
|
|
81
|
+
<p align="center"><b>Настройки → Claude Code</b> показывают, чего доске не хватает, и записывают это, сперва показав, что именно изменится: строку состояния для лимитов, хук для ответов из карточки, скилл с рабочим циклом для 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` (
|
|
304
|
+
Релизы живут в `main` (с тегами); ежедневная разработка — в `dev`. Бета выходит так же, как релиз, поэтому `latest` и кнопка обновления приносят и её. Вернуться назад: `npm install -g local-kanban@1.16.0`.
|
|
220
305
|
|
|
221
306
|
## Как убрать доску
|
|
222
307
|
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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': 'в фоне',
|
package/public/js/health.js
CHANGED
|
@@ -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-
|
|
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(--
|
|
1645
|
-
.chat-pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/routes/chat.js
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
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. */
|
package/src/routes/system.js
CHANGED
|
@@ -40,9 +40,20 @@ function selfPkg() {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function isNewer(candidate, current) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|