k0-board 0.2.0 → 0.3.0
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 +51 -1
- package/README.md +83 -4
- package/bin/k0-board.js +14 -0
- package/package.json +1 -1
- package/server/db.js +11 -0
- package/server/idle.js +56 -0
- package/server/index.js +55 -1
- package/server/launcher.js +5 -1
- package/server/paths.js +5 -4
- package/server/settings.js +121 -0
- package/web/board.css +4 -0
- package/web/board.js +22 -4
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,54 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0]
|
|
10
|
+
|
|
11
|
+
k0 could already tell you what each session was costing you in memory, and `Close` on a card was
|
|
12
|
+
already how you gave it back. This version is k0 remembering to do that for you: a terminal left
|
|
13
|
+
untouched all day is shut by itself, and the card it belongs to stays exactly where it was, ready
|
|
14
|
+
to be picked up again. It is careful about which ones — never one that is working, never one with
|
|
15
|
+
a question on screen — and it is the first thing in k0 you can change without changing k0, in a
|
|
16
|
+
settings file that writes itself.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **The terminals you have stopped using close themselves, and the work stays.** A session left
|
|
21
|
+
open goes on costing memory whether or not anybody is looking at it — the `claude` process, the
|
|
22
|
+
MCP servers it started, the browsers those opened — and `Close` on a card has always been the
|
|
23
|
+
cure. Now k0 remembers to do it for you: **after twelve hours with nothing happening, a yellow
|
|
24
|
+
card's terminal is shut and its memory given back**. Nothing is lost. The card stays exactly
|
|
25
|
+
where it is with the status it had, and **Resume** picks the conversation up where it was, the
|
|
26
|
+
same as it always did. Only yellow — *Your turn* — is ever touched: a card that is **working**
|
|
27
|
+
is somebody mid-thought, and a card with a **question or a plan on screen** would lose the very
|
|
28
|
+
thing it was showing you, because a dialog that is open has not been written to the transcript
|
|
29
|
+
yet. A session sitting in a **shell** is left alone too — there may be a command of yours
|
|
30
|
+
running in it, and k0 cannot see what. A window you were using an hour ago is never closed,
|
|
31
|
+
whatever the card's history says: every clock k0 has for that session is consulted and the
|
|
32
|
+
newest one wins.
|
|
33
|
+
- **A card says who closed it, and the top bar says the closing is on.** Where a card used to read
|
|
34
|
+
*session closed* it now reads *closed automatically* if it was k0 that tidied it away — same
|
|
35
|
+
italic, same place, one word different. And the memory chip at the end of the top bar carries the
|
|
36
|
+
hours in lighter type: **RAM 79% · CPU 19% · closes at 8h**, with the whole sentence on hover.
|
|
37
|
+
That is where it belongs, because that is where the memory is already being talked about, and
|
|
38
|
+
something that closes your windows for you should never be a surprise. Switched off, the chip
|
|
39
|
+
says nothing rather than *0h* — which would read as "closes immediately" — and the hover says so
|
|
40
|
+
instead.
|
|
41
|
+
- **Settings, in a file that is the list of them.** `~/.k0/config.json` — on Windows
|
|
42
|
+
`%LOCALAPPDATA%\k0\config.json` — written out on the first run with every setting already in it
|
|
43
|
+
at its default, so opening it is how you find out what there is to change. It sits beside the
|
|
44
|
+
board rather than inside the app, so an update cannot throw it away, and k0 picks up a change by
|
|
45
|
+
itself without anything being restarted. `closeIdleTerminalsAfterHours` is the first entry:
|
|
46
|
+
**set it to `0` and the closing above never happens**. There is no settings page on the board and
|
|
47
|
+
there is not going to be one — the board is for what changes during a working day —
|
|
48
|
+
so `k0-board doctor` prints the whole list too, with what is in force and where the file is.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **Resuming a session no longer types the old prompt back in.** A card's prompt belongs to the
|
|
53
|
+
moment the session starts; on **Resume** the conversation had already answered it hours ago, and
|
|
54
|
+
putting it back under the cursor was at best something stale in the way — at worst it was sent,
|
|
55
|
+
because where pasting is not available k0 types it instead. Start is unchanged.
|
|
56
|
+
|
|
9
57
|
## [0.2.0]
|
|
10
58
|
|
|
11
59
|
k0 could always tell you how the work stood. This version lets it tell you whether the work is
|
|
@@ -226,7 +274,9 @@ here is everything that had to change for it to be somebody else's tool as well.
|
|
|
226
274
|
- The personal fixtures k0 grew up on. `test/clean.test.mjs` now fails the build if any of them
|
|
227
275
|
come back.
|
|
228
276
|
|
|
229
|
-
[Unreleased]: https://github.com/alessio-ragni/k0-board/compare/v0.
|
|
277
|
+
[Unreleased]: https://github.com/alessio-ragni/k0-board/compare/v0.3.0...HEAD
|
|
278
|
+
[0.3.0]: https://github.com/alessio-ragni/k0-board/compare/v0.2.0...v0.3.0
|
|
279
|
+
[0.2.0]: https://github.com/alessio-ragni/k0-board/compare/v0.1.2...v0.2.0
|
|
230
280
|
[0.1.2]: https://github.com/alessio-ragni/k0-board/compare/v0.1.1...v0.1.2
|
|
231
281
|
[0.1.1]: https://github.com/alessio-ragni/k0-board/compare/v0.1.0...v0.1.1
|
|
232
282
|
[0.1.0]: https://github.com/alessio-ragni/k0-board/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ Nothing on this list happens before you have seen it and said yes. Everything on
|
|
|
133
133
|
| What | Where | Why |
|
|
134
134
|
|---|---|---|
|
|
135
135
|
| A copy of k0 | `~/.k0/app` | so the service does not point at a temporary npx cache |
|
|
136
|
-
| Your board, logs and cache | `~/.k0/` | `%LOCALAPPDATA%\k0` on Windows |
|
|
136
|
+
| Your board, settings, logs and cache | `~/.k0/` | `%LOCALAPPDATA%\k0` on Windows |
|
|
137
137
|
| A service that starts at login | LaunchAgents · systemd user unit · Task Scheduler | so k0 is simply there |
|
|
138
138
|
| **macOS only** — one administrator rule | `/etc/sudoers.d/k0-pmset` | to keep the Mac awake with the lid closed. Asks for your password once, and grants exactly two command lines |
|
|
139
139
|
| **macOS only** — one key binding | your Terminal profile (backed up first) | so Shift+Enter starts a new line |
|
|
@@ -200,8 +200,10 @@ k0 makes **no network requests**. Not one, not even for a font.
|
|
|
200
200
|
- **double click** — brings that session's terminal back to the front, even if you had minimised
|
|
201
201
|
it. It is what stops you losing track of which window is which.
|
|
202
202
|
|
|
203
|
-
- **Resume** — appears once
|
|
204
|
-
where it was.
|
|
203
|
+
- **Resume** — appears once the terminal has been closed, by you or by k0 after a long silence: it
|
|
204
|
+
reopens the same conversation exactly where it was. The card's prompt is **not** put back in —
|
|
205
|
+
that conversation answered it hours ago, and typing it in again would be at best something stale
|
|
206
|
+
in the way.
|
|
205
207
|
|
|
206
208
|
- **Done** — closes the work **and its terminal**, with no questions: undo it with **Reopen**, and
|
|
207
209
|
pick the conversation up again with **Resume**. It is not on backlog cards: they never started.
|
|
@@ -512,6 +514,47 @@ Reading the process table costs little on a calm machine and a lot on a struggli
|
|
|
512
514
|
exactly when you look at it. So it is sampled every three seconds, and **only while the board is
|
|
513
515
|
open**.
|
|
514
516
|
|
|
517
|
+
### And the ones you forget, k0 closes for you
|
|
518
|
+
|
|
519
|
+
The paragraph above assumes you noticed. Most of the time nobody does: the window that is costing
|
|
520
|
+
you a gigabyte is the one you stopped thinking about yesterday. So **after twelve hours with
|
|
521
|
+
nothing happening, k0 does that `Close` itself** — the session is stopped, its window goes, and the
|
|
522
|
+
memory comes back.
|
|
523
|
+
|
|
524
|
+
Nothing is lost by it. The card stays exactly where it is with the status it had, and **Resume**
|
|
525
|
+
picks the conversation up where it was. That is the whole reason this is safe: a closed terminal
|
|
526
|
+
has never been a lost session.
|
|
527
|
+
|
|
528
|
+
**Only yellow is ever touched** — *Your turn*, the ball in your court and nothing happening. The
|
|
529
|
+
rest is left alone, and each for its own reason:
|
|
530
|
+
|
|
531
|
+
- **Working** and **Planning** are somebody mid-thought. Stopping one is not memory saved, it is
|
|
532
|
+
work lost — the same rule the `Close` link has always followed.
|
|
533
|
+
- **Needs answer** and **Needs approval** would lose the very thing they were showing you. A
|
|
534
|
+
question and a finished plan are drawn by the terminal and are not written to the transcript
|
|
535
|
+
until they are answered, so closing that window throws the question away.
|
|
536
|
+
- A session sitting in a **shell** is left alone as well. That is a shell you dropped into, and
|
|
537
|
+
there may be a command of yours running in it; k0 cannot see what it is, so k0 does not touch it.
|
|
538
|
+
On the board it reads as yellow like any other, which is exactly why this one has to be said.
|
|
539
|
+
|
|
540
|
+
And a window you were using an hour ago is never closed, whatever the card's history says. k0 has
|
|
541
|
+
four clocks for a session — two of its own and two Claude Code keeps — and **the newest one wins**.
|
|
542
|
+
Being wrong in that direction leaves a window open until tomorrow; being wrong the other way closes
|
|
543
|
+
a terminal you were about to go back to.
|
|
544
|
+
|
|
545
|
+
A card k0 tidied away says so: where one you closed yourself reads *session closed*, this one reads
|
|
546
|
+
*closed automatically*. Same italic, same place, one word different.
|
|
547
|
+
|
|
548
|
+
And the memory chip at the end of the top bar says it is watching, and from when: **RAM 79% · CPU
|
|
549
|
+
19% · closes at 8h**, in lighter type than the numbers beside it. That is the whole announcement,
|
|
550
|
+
and it is on that chip rather than anywhere else because that is where the memory is already being
|
|
551
|
+
talked about — something that closes your windows for you should never be a surprise. Hover for
|
|
552
|
+
the sentence. Switched off, the chip says nothing at all rather than *0h*, which would read as
|
|
553
|
+
"closes immediately", and the hover is what tells you it is off.
|
|
554
|
+
|
|
555
|
+
Twelve hours is what it does if you never say otherwise. `closeIdleTerminalsAfterHours` in the
|
|
556
|
+
settings file changes it, and **`0` switches it off** — see below.
|
|
557
|
+
|
|
515
558
|
---
|
|
516
559
|
|
|
517
560
|
## Filling the board with what you have already done
|
|
@@ -722,6 +765,38 @@ Two side effects of the Cmd+V k0 uses to write your prompt, worth knowing:
|
|
|
722
765
|
|
|
723
766
|
---
|
|
724
767
|
|
|
768
|
+
## Settings
|
|
769
|
+
|
|
770
|
+
There is no settings page on the board, and there is not going to be one. The board is for what
|
|
771
|
+
changes during a working day; a number you set once and then never look at again is the opposite of
|
|
772
|
+
that, and putting it on screen would cost the one page that has to stay readable in exchange for
|
|
773
|
+
nothing.
|
|
774
|
+
|
|
775
|
+
So the settings are a file, and **the file is the list**:
|
|
776
|
+
|
|
777
|
+
```
|
|
778
|
+
~/.k0/config.json macOS and Linux
|
|
779
|
+
%LOCALAPPDATA%\k0\config.json Windows
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
k0 writes it on the first run with **every setting already in it at its default**, so opening it is
|
|
783
|
+
how you find out what there is to change. It sits beside the board rather than inside the app —
|
|
784
|
+
`npx` unpacks into a cache npm is free to wipe, and `k0-board install` overwrites the app
|
|
785
|
+
directory, so a setting kept in there would not survive an update. Change it and k0 picks it up by
|
|
786
|
+
itself on the next round: nothing to restart.
|
|
787
|
+
|
|
788
|
+
| | |
|
|
789
|
+
|---|---|
|
|
790
|
+
| `closeIdleTerminalsAfterHours` | How long a yellow card's terminal may sit there before k0 closes it and gives the memory back. `12` if you say nothing. **`0` switches it off.** Anything under an hour is treated as an hour — below that this stops being a tidy-up and starts closing windows while you are using them. |
|
|
791
|
+
|
|
792
|
+
Delete a line to go back to its default; a file k0 cannot read is ignored altogether and the
|
|
793
|
+
defaults hold, rather than k0 refusing to start over a stray comma.
|
|
794
|
+
|
|
795
|
+
**`k0-board doctor` prints the same list**, with the value actually in force and where the file is,
|
|
796
|
+
for the times you would rather ask than go and look.
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
725
800
|
## How it is put together
|
|
726
801
|
|
|
727
802
|
No dependencies, nothing to compile. Change a file, reload the page.
|
|
@@ -744,7 +819,11 @@ server/
|
|
|
744
819
|
guard.js who is allowed to talk to this server at all: the Host and the Origin
|
|
745
820
|
db.js SQLite (node:sqlite): the card, session_event, pref and dev_server tables —
|
|
746
821
|
docs/database.md
|
|
747
|
-
paths.js where the board, the logs and the cache live
|
|
822
|
+
paths.js where the board, the logs, the settings and the cache live
|
|
823
|
+
settings.js the few things you can change, and the file that is the list of them —
|
|
824
|
+
~/.k0/config.json, written out with everything in it and re-read when it changes
|
|
825
|
+
idle.js which terminals have sat still long enough to be closed and given back. Only the
|
|
826
|
+
deciding: it kills nothing, so every rule in it can be proved without a machine
|
|
748
827
|
watcher.js reads Claude Code's own files and derives the statuses; also renames a session
|
|
749
828
|
git.js the only one that talks to git: what is committed, what is pushed, whose it is,
|
|
750
829
|
and — for the ChangeLog — what the commits actually said
|
package/bin/k0-board.js
CHANGED
|
@@ -48,6 +48,7 @@ if (Number(process.versions.node.split('.')[0]) < MIN_NODE) {
|
|
|
48
48
|
|
|
49
49
|
const { name: platformName, capabilities, notes, service, extras, shell } = await import('../platform/index.js')
|
|
50
50
|
const paths = await import('../server/paths.js')
|
|
51
|
+
const settings = await import('../server/settings.js')
|
|
51
52
|
|
|
52
53
|
const APP_DIR = flag('from-source') ? SOURCE : paths.APP_DIR
|
|
53
54
|
const ENTRY = path.join(APP_DIR, 'server', 'index.js')
|
|
@@ -420,6 +421,19 @@ async function doctor() {
|
|
|
420
421
|
]
|
|
421
422
|
for (const [what, yes] of rows) (yes ? ok : warn)(what)
|
|
422
423
|
|
|
424
|
+
// The settings, in full, with what is actually in force. k0 has no settings page and is not
|
|
425
|
+
// getting one, so this — and the file itself — is where the list lives. Printing every key
|
|
426
|
+
// rather than only the ones somebody has changed is the point: it is an inventory.
|
|
427
|
+
step('Settings')
|
|
428
|
+
const conf = settings.status()
|
|
429
|
+
say(dim(` ${conf.path}${conf.exists ? '' : ' (not written yet — the defaults are in force)'}`))
|
|
430
|
+
if (conf.broken) warn('this file could not be read: it is being ignored and the defaults are in force')
|
|
431
|
+
for (const [key, value] of Object.entries(conf.values)) {
|
|
432
|
+
const dflt = settings.DEFAULTS[key]
|
|
433
|
+
const off = key === 'closeIdleTerminalsAfterHours' && value === 0
|
|
434
|
+
say(` ${bold(key)} ${off ? 'off' : value}${value === dflt ? dim(' (default)') : dim(` (default ${dflt})`)}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
423
437
|
const explained = Object.entries(notes)
|
|
424
438
|
if (explained.length) {
|
|
425
439
|
step('Why not')
|
package/package.json
CHANGED
package/server/db.js
CHANGED
|
@@ -73,6 +73,7 @@ for (const [col, decl] of [
|
|
|
73
73
|
['imported_at', 'INTEGER'],
|
|
74
74
|
['work_path', 'TEXT'],
|
|
75
75
|
['head_at_start', 'TEXT'],
|
|
76
|
+
['auto_closed', 'INTEGER NOT NULL DEFAULT 0'],
|
|
76
77
|
]) {
|
|
77
78
|
const has = db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get('card', col)
|
|
78
79
|
if (!has) db.exec(`ALTER TABLE card ADD COLUMN ${col} ${decl}`)
|
|
@@ -214,6 +215,16 @@ export function setWorkPath(id, workPath) {
|
|
|
214
215
|
return getCard(id)
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Who shut this session's terminal: you, or k0 tidying up after a long silence. The card says so
|
|
220
|
+
* in one word, and the word has to be right — a window you closed yourself two minutes ago must
|
|
221
|
+
* not claim k0 did it. Does not touch `updated_at`: this is not work on the card.
|
|
222
|
+
*/
|
|
223
|
+
export function setAutoClosed(id, on) {
|
|
224
|
+
db.prepare('UPDATE card SET auto_closed = ? WHERE id = ?').run(on ? 1 : 0, id)
|
|
225
|
+
return getCard(id)
|
|
226
|
+
}
|
|
227
|
+
|
|
217
228
|
/** Where HEAD was when the session started: everything after it is this session's doing. */
|
|
218
229
|
export function setHeadAtStart(id, sha) {
|
|
219
230
|
db.prepare('UPDATE card SET head_at_start = ? WHERE id = ?').run(sha || null, id)
|
package/server/idle.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which terminals have been sitting there long enough to be worth giving back.
|
|
3
|
+
*
|
|
4
|
+
* A session left open costs memory whether or not anybody is looking at it: the `claude` process,
|
|
5
|
+
* the MCP servers it started, the browsers those opened. `Close` on a card has always been the
|
|
6
|
+
* cure — it stops the session and shuts its window, leaves the card exactly where it is, and
|
|
7
|
+
* `Resume` picks the conversation up where it was. This is that same gesture, remembered for you.
|
|
8
|
+
*
|
|
9
|
+
* Only the deciding lives here. Nothing in this file kills anything, opens anything or reads a
|
|
10
|
+
* disk, which is what lets every rule below be proved without a machine to prove it on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const HOUR = 3600000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The cards whose terminal should go, most of the rules being about what to leave alone.
|
|
17
|
+
*
|
|
18
|
+
* Yellow only — `IDLE`, the ball in your court. Not because the others are less forgotten but
|
|
19
|
+
* because of what closing them would destroy: a question and a finished plan are drawn by the
|
|
20
|
+
* terminal and are not in the transcript yet, so shutting that window throws away the very thing
|
|
21
|
+
* it was showing you. `WORKING` and `PLANNING` are somebody mid-thought. And a session Claude
|
|
22
|
+
* Code reports as `shell` is a shell you dropped into, which may well have a command of yours
|
|
23
|
+
* running in it — k0 does not know what, so k0 does not touch it.
|
|
24
|
+
*/
|
|
25
|
+
export function dueForClose({ cards, live, hours, now = Date.now() }) {
|
|
26
|
+
if (!(hours > 0)) return [] // switched off, and nothing to think about
|
|
27
|
+
const deadline = now - hours * HOUR
|
|
28
|
+
return cards.filter((card) => {
|
|
29
|
+
if (card.completed_at || !card.session_id || !card.session_alive) return false
|
|
30
|
+
if (card.status !== 'IDLE') return false
|
|
31
|
+
const session = live.get(card.session_id)
|
|
32
|
+
if (!session || session.status !== 'idle') return false
|
|
33
|
+
return lastSignOfLife(card, session) <= deadline
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The most recent sign that something happened here, from every source that keeps one, and the
|
|
39
|
+
* newest of them wins.
|
|
40
|
+
*
|
|
41
|
+
* Taking the maximum is the whole safety of this feature. Being wrong in that direction leaves a
|
|
42
|
+
* window open and costs a few hundred megabytes until tomorrow; being wrong the other way closes
|
|
43
|
+
* a terminal somebody was about to go back to. So a single stale clock can never be the one that
|
|
44
|
+
* decides.
|
|
45
|
+
*
|
|
46
|
+
* `updatedAt` and `statusUpdatedAt` come out of Claude Code's own `~/.claude/sessions/<pid>.json`,
|
|
47
|
+
* which k0 already reads every second and until now threw away. They move when the session really
|
|
48
|
+
* changes state and are not a heartbeat — a session that had been busy for twenty-three minutes
|
|
49
|
+
* was carrying timestamps twenty-three minutes old — so they say what they appear to say.
|
|
50
|
+
*
|
|
51
|
+
* `updated_at` matters for the case none of the others cover: resuming a card leaves the status at
|
|
52
|
+
* `IDLE`, so no new event is written and `status_since` stays as old as it was.
|
|
53
|
+
*/
|
|
54
|
+
export function lastSignOfLife(card, session) {
|
|
55
|
+
return Math.max(card.updated_at || 0, card.status_since || 0, session.updatedAt || 0, session.statusUpdatedAt || 0)
|
|
56
|
+
}
|
package/server/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
relayoutWindows,
|
|
17
17
|
} from './launcher.js'
|
|
18
18
|
import * as mode from './mode.js'
|
|
19
|
+
import * as settings from './settings.js'
|
|
20
|
+
import { dueForClose } from './idle.js'
|
|
19
21
|
import { listProjects, onDisk, projectName } from './projects.js'
|
|
20
22
|
import { scanSessions } from './sessions.js'
|
|
21
23
|
import * as git from './git.js'
|
|
@@ -65,7 +67,50 @@ function tick() {
|
|
|
65
67
|
// The power levers are checked once a minute rather than every round: reading the power
|
|
66
68
|
// state costs processes, and the things it has to follow — the mains plugged in or out, the
|
|
67
69
|
// battery going down — do not change from one second to the next.
|
|
68
|
-
if (rounds
|
|
70
|
+
if (rounds % 60 === 0) mode.guard()
|
|
71
|
+
// The forgotten terminals, on the same cadence but half a minute off it, so the two jobs that
|
|
72
|
+
// only run once a minute never land in the same second as each other.
|
|
73
|
+
if (rounds % 60 === 30) sweepIdle(live)
|
|
74
|
+
rounds++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Gives back the memory of the windows you have stopped using.
|
|
79
|
+
*
|
|
80
|
+
* It does exactly what the `Close` link on a card does, and on purpose the same three moves and
|
|
81
|
+
* not a path of its own: stop the session, shut the window, forget the window. The card stays
|
|
82
|
+
* where it is with the status it had, `session_id` is untouched, and `Resume` picks the
|
|
83
|
+
* conversation up where it was — a closed terminal is not a lost session.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately not awaited by `tick`: closing a terminal waits up to two seconds for the process
|
|
86
|
+
* to go, and the watching loop must not be held up by it. Hence the latch, the same one
|
|
87
|
+
* `machine.js` and `servers.js` keep for their own sampling — `setInterval` will happily start a
|
|
88
|
+
* second round on top of the first.
|
|
89
|
+
*/
|
|
90
|
+
let sweeping = false
|
|
91
|
+
async function sweepIdle(live) {
|
|
92
|
+
if (sweeping) return
|
|
93
|
+
const hours = settings.read().closeIdleTerminalsAfterHours
|
|
94
|
+
const due = dueForClose({ cards: db.listCards(), live, hours })
|
|
95
|
+
if (!due.length) return
|
|
96
|
+
sweeping = true
|
|
97
|
+
try {
|
|
98
|
+
for (const card of due) {
|
|
99
|
+
try {
|
|
100
|
+
await closeTerminal({ winId: card.terminal_window_id, pid: live.get(card.session_id)?.pid })
|
|
101
|
+
db.setTerminalWindow(card.id, null) // that window is gone; Resume opens a new one
|
|
102
|
+
db.setAutoClosed(card.id, true)
|
|
103
|
+
console.log(`k0 — "${card.title}" sat still for ${hours}h: its terminal is closed, Resume picks it up`)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// One window that refuses to go must not take the others with it, and must certainly not
|
|
106
|
+
// take the server: nothing is awaiting this, so a rejection escaping here would end the
|
|
107
|
+
// process outright. It runs unattended — the next round is the retry.
|
|
108
|
+
console.error(`k0 — could not close the terminal of "${card.title}": ${String(err?.message || err)}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
sweeping = false
|
|
113
|
+
}
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
/** When somebody last looked at the board. */
|
|
@@ -186,6 +231,10 @@ function board() {
|
|
|
186
231
|
mode: mode.current(),
|
|
187
232
|
lid: mode.lid(),
|
|
188
233
|
reason: mode.reason(),
|
|
234
|
+
// After how long a forgotten terminal is closed, so the machine chip can say that it is
|
|
235
|
+
// watching and after how long. Zero means it is switched off, and then the chip says nothing
|
|
236
|
+
// rather than saying "0h" — a number that would read as "closes immediately".
|
|
237
|
+
closeIdleAfterHours: settings.read().closeIdleTerminalsAfterHours,
|
|
189
238
|
// What this machine can and cannot do, so the board greys out the rest and says why
|
|
190
239
|
// instead of offering a button that quietly does nothing.
|
|
191
240
|
platform: platformReport(),
|
|
@@ -623,6 +672,7 @@ async function api(req, res, url) {
|
|
|
623
672
|
const pid = readLiveSessions().get(card.session_id)?.pid
|
|
624
673
|
const out = await closeTerminal({ winId: card.terminal_window_id, pid })
|
|
625
674
|
db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
|
|
675
|
+
db.setAutoClosed(id, false) // this one was you: the card must not say otherwise
|
|
626
676
|
tick() // so the answer already carries the closed card, without waiting for the loop
|
|
627
677
|
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
628
678
|
}
|
|
@@ -642,6 +692,7 @@ async function api(req, res, url) {
|
|
|
642
692
|
try {
|
|
643
693
|
const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
|
|
644
694
|
db.setTerminalWindow(id, out.winId)
|
|
695
|
+
db.setAutoClosed(id, false) // there is a window again: whoever shut the last one is history
|
|
645
696
|
tick()
|
|
646
697
|
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
647
698
|
} catch (err) {
|
|
@@ -694,6 +745,9 @@ http
|
|
|
694
745
|
}
|
|
695
746
|
})
|
|
696
747
|
.listen(PORT, '127.0.0.1', () => {
|
|
748
|
+
// The settings file, written out with everything in it the first time k0 runs: it is the only
|
|
749
|
+
// list of what can be changed, so it has to exist before anybody goes looking for it.
|
|
750
|
+
settings.ensure()
|
|
697
751
|
tick()
|
|
698
752
|
// The mode is remembered, and at startup it puts the machine back as it was: the sleep
|
|
699
753
|
// levers and, where needed, the font size of terminals left open. The server restarts often
|
package/server/launcher.js
CHANGED
|
@@ -105,7 +105,11 @@ export async function launch({ card, sessionId, mode = 'start' }) {
|
|
|
105
105
|
const up = await waitForSession(sessionId)
|
|
106
106
|
|
|
107
107
|
if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
|
|
108
|
-
|
|
108
|
+
// The prompt belongs to the beginning. Resuming picks up a conversation that answered it hours
|
|
109
|
+
// ago, and putting it back under the cursor is at best a stale instruction in the way — at
|
|
110
|
+
// worst it is sent, because where pasting is unavailable the fallback below types it. This
|
|
111
|
+
// matters more now that k0 closes forgotten terminals by itself: a Resume is no longer rare.
|
|
112
|
+
if (resume || !up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
|
|
109
113
|
|
|
110
114
|
await waitForPrompt(winId)
|
|
111
115
|
|
package/server/paths.js
CHANGED
|
@@ -12,10 +12,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
* there is on borrowed time. Data now lives in a directory owned by the user, and the
|
|
13
13
|
* installed copy of the app is disposable.
|
|
14
14
|
*
|
|
15
|
-
* ~/.k0/k0.db
|
|
16
|
-
* ~/.k0/
|
|
17
|
-
* ~/.k0/
|
|
18
|
-
* ~/.k0/
|
|
15
|
+
* ~/.k0/k0.db the board
|
|
16
|
+
* ~/.k0/config.json the settings, written out with everything in it on the first run
|
|
17
|
+
* ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
|
|
18
|
+
* ~/.k0/logs/ service stdout/stderr
|
|
19
|
+
* ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
|
|
19
20
|
*
|
|
20
21
|
* On Windows the same tree goes under %LOCALAPPDATA%\k0, which is the local-only,
|
|
21
22
|
* not-roamed location Windows expects for this kind of state.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { HOME } from './paths.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The few things about k0 you are allowed to change, and the file they live in.
|
|
7
|
+
*
|
|
8
|
+
* k0 has never had one of these, and this is deliberately not the first room of a settings
|
|
9
|
+
* panel. The board shows what changes during a working day; a number you set once and then never
|
|
10
|
+
* look at again is the opposite of that, and putting it on screen would cost the one page that
|
|
11
|
+
* has to stay readable in exchange for nothing.
|
|
12
|
+
*
|
|
13
|
+
* So it is a file, and the file IS the list: k0 writes it out with every setting already in it at
|
|
14
|
+
* its default, so that opening it is how you find out what there is to change. `k0-board doctor`
|
|
15
|
+
* prints the same thing, for anyone who would rather ask than look.
|
|
16
|
+
*
|
|
17
|
+
* It lives beside the database and not inside the app, because the app directory is disposable —
|
|
18
|
+
* `npx` unpacks into a cache npm may wipe, and `k0-board install` overwrites it — and a setting
|
|
19
|
+
* an update throws away is not a setting. And it is read again whenever it changes, so editing
|
|
20
|
+
* it takes effect on the next round rather than after a restart.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `K0_CONFIG` is how the tests stay away from the real file, the way `K0_DB` does for the board. */
|
|
24
|
+
export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every setting there is, and what it is worth if you never touch the file. No default is written
|
|
28
|
+
* down anywhere else: this object is the whole answer to "and if I leave it alone?".
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULTS = {
|
|
31
|
+
closeIdleTerminalsAfterHours: 12,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shortest timeout that is still a timeout. The sweep goes round once a minute, and a
|
|
36
|
+
* terminal you were typing into ten minutes ago is not one you have forgotten: below an hour this
|
|
37
|
+
* stops being a tidy-up and becomes a machine closing windows while you are using them. A number
|
|
38
|
+
* under it is raised rather than refused — the intent was clearly "be aggressive about it", and an
|
|
39
|
+
* hour is as aggressive as this is willing to be.
|
|
40
|
+
*/
|
|
41
|
+
export const MIN_HOURS = 1
|
|
42
|
+
|
|
43
|
+
/** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
|
|
44
|
+
const NOTE =
|
|
45
|
+
'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
|
|
46
|
+
'Delete a line to go back to its default. Hours at 0 switch that closing off entirely. ' +
|
|
47
|
+
'See Settings in the README.'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a terminal may sit there. Anything that is not a number falls back to the default
|
|
51
|
+
* rather than blowing up: this arrives from a file somebody edits by hand, and from there
|
|
52
|
+
* anything can arrive. Zero and below mean off, which is how the whole thing is switched off
|
|
53
|
+
* without a second switch to be kept in step with the first.
|
|
54
|
+
*/
|
|
55
|
+
export function idleHours(raw) {
|
|
56
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULTS.closeIdleTerminalsAfterHours
|
|
57
|
+
const n = Number(raw)
|
|
58
|
+
if (!Number.isFinite(n)) return DEFAULTS.closeIdleTerminalsAfterHours
|
|
59
|
+
if (n <= 0) return 0
|
|
60
|
+
return Math.max(MIN_HOURS, n)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
|
|
64
|
+
export function normalise(raw) {
|
|
65
|
+
const o = raw && typeof raw === 'object' ? raw : {}
|
|
66
|
+
return { closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cached = null
|
|
70
|
+
let stamp = null // the mtime the cache was built from, or null for "there was no file"
|
|
71
|
+
let broken = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The settings in force. The file is only re-read when its mtime moves, so this can be called
|
|
75
|
+
* from the watching loop without costing a read a second, and changing the file still lands
|
|
76
|
+
* without a restart.
|
|
77
|
+
*/
|
|
78
|
+
export function read() {
|
|
79
|
+
let mtime = null
|
|
80
|
+
try {
|
|
81
|
+
mtime = fs.statSync(PATH).mtimeMs
|
|
82
|
+
} catch {
|
|
83
|
+
/* no file: the defaults hold, and there is nothing to remember */
|
|
84
|
+
}
|
|
85
|
+
if (cached && stamp === mtime) return cached
|
|
86
|
+
stamp = mtime
|
|
87
|
+
if (mtime === null) broken = false // a file that is not there is not a file that is broken
|
|
88
|
+
cached = normalise(mtime === null ? null : parse())
|
|
89
|
+
return cached
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parse() {
|
|
93
|
+
try {
|
|
94
|
+
broken = false
|
|
95
|
+
return JSON.parse(fs.readFileSync(PATH, 'utf8'))
|
|
96
|
+
} catch {
|
|
97
|
+
// Half-saved by an editor, or one comma too many. k0 goes on with the defaults rather than
|
|
98
|
+
// refusing to start, and `doctor` is where you find out the file is being ignored — silence
|
|
99
|
+
// would be the worst of both: a setting you believe is in force and is not.
|
|
100
|
+
broken = true
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Writes the file out, with everything in it, if it is not there yet. Called explicitly at
|
|
107
|
+
* startup rather than from an import: a module that writes to your disk merely because somebody
|
|
108
|
+
* mentioned it is a module nobody can reason about.
|
|
109
|
+
*/
|
|
110
|
+
export function ensure() {
|
|
111
|
+
if (fs.existsSync(PATH)) return false
|
|
112
|
+
fs.mkdirSync(path.dirname(PATH), { recursive: true })
|
|
113
|
+
fs.writeFileSync(PATH, JSON.stringify({ '//': NOTE, ...DEFAULTS }, null, 2) + '\n')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the doctor prints: where the file is, whether it could be read, and what is in force. */
|
|
118
|
+
export function status() {
|
|
119
|
+
const values = read()
|
|
120
|
+
return { path: PATH, exists: stamp !== null, broken, values }
|
|
121
|
+
}
|
package/web/board.css
CHANGED
|
@@ -88,6 +88,10 @@ body {
|
|
|
88
88
|
flex: none;
|
|
89
89
|
background: var(--calm);
|
|
90
90
|
}
|
|
91
|
+
/* That k0 is looking after the memory for you is worth knowing once and then forgetting: it sits
|
|
92
|
+
on the same chip, lighter than the numbers beside it, because the numbers are what you came to
|
|
93
|
+
read and this is only there so the closing is never a surprise. */
|
|
94
|
+
.gauge .idle { font-weight: 500; opacity: .6; margin-left: -2px; white-space: nowrap; }
|
|
91
95
|
.gauge.warn i { background: var(--warn); }
|
|
92
96
|
.gauge.bad i { background: var(--alert); }
|
|
93
97
|
/* Solid red only when it is critical: the one moment it is allowed to interrupt. */
|
package/web/board.js
CHANGED
|
@@ -111,7 +111,13 @@ function postit(card, now) {
|
|
|
111
111
|
: ''
|
|
112
112
|
}
|
|
113
113
|
<div class="foot">
|
|
114
|
-
${
|
|
114
|
+
${
|
|
115
|
+
// Whoever shut the window, said in the same breath and the same weight — this line is
|
|
116
|
+
// already only an italic, and it stays one. Which of the two it is does not need its own
|
|
117
|
+
// place in the redraw signature: `auto_closed` never moves without `session_alive`
|
|
118
|
+
// moving with it, and that one is in there.
|
|
119
|
+
dead ? `<span class="dead">${card.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
|
|
120
|
+
}
|
|
115
121
|
<span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
|
|
116
122
|
</div>
|
|
117
123
|
<div class="actions"></div>`
|
|
@@ -559,7 +565,7 @@ function repoColumn(projects, shown) {
|
|
|
559
565
|
* is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
|
|
560
566
|
* while it says all is well, amber at the first warning, red when it is critical.
|
|
561
567
|
*/
|
|
562
|
-
function renderMachine(m, cards) {
|
|
568
|
+
function renderMachine(m, cards, idleHours = 0) {
|
|
563
569
|
const el = $('#gauge')
|
|
564
570
|
if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
|
|
565
571
|
el.removeAttribute('hidden')
|
|
@@ -567,7 +573,12 @@ function renderMachine(m, cards) {
|
|
|
567
573
|
|
|
568
574
|
const ram = Math.round((m.mem.used / m.mem.total) * 100)
|
|
569
575
|
const cpu = m.cpu === null ? '—' : `${Math.round(m.cpu * 100)}%`
|
|
570
|
-
|
|
576
|
+
// Whether anybody is looking after the memory for you, and after how long. It belongs on this
|
|
577
|
+
// chip and nowhere else: this is where the memory is already being talked about, and a thing
|
|
578
|
+
// that quietly closes your windows should not be something you have to know about beforehand.
|
|
579
|
+
// Its presence is the whole message — switched off, there is nothing here rather than a nought.
|
|
580
|
+
const closes = idleHours > 0 ? `<span class="idle">· closes at ${idleHours}h</span>` : ''
|
|
581
|
+
el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}${closes}`
|
|
571
582
|
|
|
572
583
|
const top = cards.find((c) => c.id === heaviest)
|
|
573
584
|
const lines = [
|
|
@@ -577,6 +588,13 @@ function renderMachine(m, cards) {
|
|
|
577
588
|
if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
|
|
578
589
|
// What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
|
|
579
590
|
if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
|
|
591
|
+
// The chip has room for three words; the sentence that explains them goes here, and it is also
|
|
592
|
+
// the only place that can say the closing is switched off — the chip says that by staying quiet.
|
|
593
|
+
lines.push(
|
|
594
|
+
idleHours > 0
|
|
595
|
+
? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the card stays, Resume picks it up`
|
|
596
|
+
: 'closing forgotten terminals is switched off'
|
|
597
|
+
)
|
|
580
598
|
lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
|
|
581
599
|
el.title = lines.join('\n')
|
|
582
600
|
}
|
|
@@ -857,7 +875,7 @@ async function refresh() {
|
|
|
857
875
|
// What is eating most right now: it is what tints that one red and nobody else.
|
|
858
876
|
heaviest =
|
|
859
877
|
data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
|
|
860
|
-
renderMachine(machine, data.cards)
|
|
878
|
+
renderMachine(machine, data.cards, data.closeIdleAfterHours)
|
|
861
879
|
|
|
862
880
|
// Redraw only if something really changed, so the editor and the focus stay where they are.
|
|
863
881
|
// The git state is inside the signature: without it, the mark would stay the one from the
|