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 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.1.2...HEAD
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 you have closed the terminal: it reopens the same conversation exactly
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k0-board",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A board of sticky notes that drives your Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude-code",
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++ % 60 === 0) mode.guard()
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
@@ -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
- if (!up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
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 the board
16
- * ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
17
- * ~/.k0/logs/ service stdout/stderr
18
- * ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
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
- ${dead ? '<span class="dead">session closed</span>' : ''}
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
- el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}`
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