k0-board 0.2.0 → 0.4.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/server/files.js CHANGED
@@ -36,6 +36,29 @@ const DOC_EXT = new Set([
36
36
 
37
37
  export const isDoc = (p) => DOC_EXT.has(path.extname(p).toLowerCase())
38
38
 
39
+ // The other half of what is worth listing: the files that say how a project is set up. They are
40
+ // not documents — nobody prints an `.env` — but they are the ones you go looking for by hand, and
41
+ // until now the viewer was the only place in k0 you could not reach them from. They stay behind a
42
+ // switch in the page, because in a repository of prose they would only be noise.
43
+ const CONFIG_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
44
+
45
+ // `.env.local` has extension `.local`, and `.env` has no extension at all: this rule reads the
46
+ // name, not the end of it, or the commonest file of the lot would be the one that got away.
47
+ const ENV_NAME = /^\.env(\..+)?$/
48
+
49
+ export const isConfig = (p) =>
50
+ CONFIG_EXT.has(path.extname(p).toLowerCase()) || ENV_NAME.test(path.basename(p))
51
+
52
+ /** Everything the listing carries: documents to read, configuration to check. */
53
+ export const isListed = (p) => isDoc(p) || isConfig(p)
54
+
55
+ // What can be written back from the page. Configuration, and the notes kept next to it — never
56
+ // code: k0 is not an editor, and a `.js` changed in a browser tab with no undo and no syntax
57
+ // check is a good way to break a project without noticing.
58
+ const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
59
+
60
+ export const isEditable = (p) => NOTE_EXT.has(path.extname(p).toLowerCase()) || isConfig(p)
61
+
39
62
  // ── What kind of file is this ─────────────────────────────────────────────────
40
63
  const MARKDOWN = new Set(['.md', '.markdown', '.mdx'])
41
64
 
@@ -213,7 +236,17 @@ const cache = new Map()
213
236
  */
214
237
  async function scan(root) {
215
238
  const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
216
- return { git: !!names, ...(names ? withStats(root, names) : walk(root)) }
239
+ if (!names) return { git: false, ...walk(root, isListed) }
240
+
241
+ // git's listing is missing exactly the file all this was built for: an `.env` is ignored by
242
+ // definition, so `ls-files -co --exclude-standard` will never name it. So the disk is walked a
243
+ // second time for configuration alone — a handful of names, under the same bounds as the other
244
+ // walk — and what git already gave is kept as it is.
245
+ const fromGit = withStats(root, names)
246
+ const seen = new Set(fromGit.files.map((f) => f.p))
247
+ const extra = walk(root, isConfig)
248
+ for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
249
+ return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
217
250
  }
218
251
 
219
252
  const tracked = (root) =>
@@ -226,14 +259,14 @@ function withStats(root, names) {
226
259
  const files = []
227
260
  let truncated = false
228
261
  for (const p of names) {
229
- if (!isDoc(p)) continue
262
+ if (!isListed(p)) continue
230
263
  if (files.length >= MAX_FILES) {
231
264
  truncated = true
232
265
  break
233
266
  }
234
267
  try {
235
268
  const s = fs.statSync(path.join(root, p))
236
- if (s.isFile()) files.push({ p, m: Math.floor(s.mtimeMs), s: s.size })
269
+ if (s.isFile()) files.push(entry(p, s))
237
270
  } catch {
238
271
  /* deleted between the listing and now */
239
272
  }
@@ -241,7 +274,18 @@ function withStats(root, names) {
241
274
  return { files, truncated }
242
275
  }
243
276
 
244
- function walk(root) {
277
+ /**
278
+ * One row of the listing. `c` marks a configuration file, so the page can leave it out without
279
+ * having to ask a second time: one byte on the wire, one switch on the other side.
280
+ */
281
+ const entry = (p, s) => {
282
+ const row = { p, m: Math.floor(s.mtimeMs), s: s.size }
283
+ if (isConfig(p)) row.c = 1
284
+ return row
285
+ }
286
+
287
+ /** Everything under `root` that `pick` accepts. */
288
+ function walk(root, pick) {
245
289
  const files = []
246
290
  let truncated = false
247
291
 
@@ -258,18 +302,20 @@ function walk(root) {
258
302
  truncated = true
259
303
  return
260
304
  }
261
- if (e.name.startsWith('.')) continue
305
+ // A directory whose name starts with a dot is skipped whole: that one rule removes .git,
306
+ // the caches and the virtual environments in a single stroke. A dotted *file* is not —
307
+ // `.env` is a dotted name, and it is the one this walk was taught to find.
262
308
  const child = path.join(dir, e.name)
263
309
  const childRel = rel ? `${rel}/${e.name}` : e.name
264
310
  if (e.isDirectory()) {
265
- if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
311
+ if (!e.name.startsWith('.') && !SKIP.has(e.name)) rec(child, childRel, depth + 1)
266
312
  continue
267
313
  }
268
314
  // No symbolic links: a loop would send the walk round forever.
269
- if (!e.isFile() || !isDoc(e.name)) continue
315
+ if (!e.isFile() || !pick(e.name)) continue
270
316
  try {
271
317
  const s = fs.statSync(child)
272
- files.push({ p: childRel, m: Math.floor(s.mtimeMs), s: s.size })
318
+ files.push(entry(childRel, s))
273
319
  } catch {
274
320
  /* gone in the meantime */
275
321
  }
@@ -392,9 +438,9 @@ export async function changed(root, headAtStart) {
392
438
  .catch(() => []) // the starting commit may not be there any more
393
439
  : [],
394
440
  ])
395
- // Documents only here too: a "Changed 40" made of `.tsx` files would say there are forty
396
- // things to read, when there is nothing to read at all.
397
- return [...new Set([...now, ...mine])].filter(isDoc)
441
+ // What the listing carries, here too: a "Changed 40" made of `.tsx` files would say there are
442
+ // forty things to read, when there is nothing to read at all.
443
+ return [...new Set([...now, ...mine])].filter(isListed)
398
444
  }
399
445
 
400
446
  // ── Searching inside the files ────────────────────────────────────────────────
@@ -406,6 +452,8 @@ export async function changed(root, headAtStart) {
406
452
  // of a few KB, all already in the operating system's cache — it costs less than keeping an
407
453
  // index up to date, and an index goes stale.
408
454
  const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
455
+ /** Read only when the page asks for them: they are hidden by default, and so are their lines. */
456
+ const TEXTY_CONFIG = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
409
457
  const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
410
458
  const GREP_BYTES = 1 << 20
411
459
  const SNIPPET = 200
@@ -421,8 +469,13 @@ export const plain = (s) =>
421
469
  .toLowerCase()
422
470
  .replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
423
471
 
424
- /** The documents that contain all the words searched for, with the line they appear on. */
425
- export async function grep(root, query) {
472
+ /**
473
+ * The documents that contain all the words searched for, with the line they appear on.
474
+ *
475
+ * With `nerd` the configuration files are looked inside as well. It has to follow what the page
476
+ * is showing: finding a line in a file the listing is hiding would be a result you cannot open.
477
+ */
478
+ export async function grep(root, query, nerd = false) {
426
479
  const words = plain(query).split(/\s+/).filter(Boolean)
427
480
  if (!words.length) return []
428
481
 
@@ -430,7 +483,10 @@ export async function grep(root, query) {
430
483
  const hits = []
431
484
  for (const f of files) {
432
485
  if (hits.length >= GREP_FILES) break
433
- if (!TEXTY.has(path.extname(f.p).toLowerCase()) || f.s > GREP_BYTES) continue
486
+ if (f.c && !nerd) continue
487
+ const ext = path.extname(f.p).toLowerCase()
488
+ const texty = TEXTY.has(ext) || (nerd && (TEXTY_CONFIG.has(ext) || ENV_NAME.test(path.basename(f.p))))
489
+ if (!texty || f.s > GREP_BYTES) continue
434
490
  let text
435
491
  try {
436
492
  text = fs.readFileSync(path.join(root, f.p), 'utf8')
@@ -484,5 +540,40 @@ export function read(abs) {
484
540
  return { kind, ...meta, text, truncated }
485
541
  }
486
542
 
543
+ /**
544
+ * Writes the file back, or refuses.
545
+ *
546
+ * `was` is the modification time the page was handed when it opened the file. If the file on disk
547
+ * has moved on since, the write does not happen: somebody else — a session, an editor, a script —
548
+ * has written it in the meantime, and overwriting silently would throw their work away. The page
549
+ * is told, and it still has what you typed.
550
+ *
551
+ * The bytes go to a neighbouring name and are then renamed over the original, which on the same
552
+ * filesystem is one indivisible step: an interrupted save leaves the old file whole rather than
553
+ * half the new one. The mode is carried over, or a `.env` at 600 would come back readable by all.
554
+ */
555
+ export function write(abs, text, was) {
556
+ const s = fs.statSync(abs)
557
+ if (!s.isFile()) throw new Error('Not a file')
558
+ if (Math.floor(s.mtimeMs) !== was) {
559
+ const clash = new Error('This file changed on disk while you were editing it. Nothing was saved.')
560
+ clash.conflict = true
561
+ throw clash
562
+ }
563
+ const tmp = `${abs}.k0-tmp`
564
+ try {
565
+ fs.writeFileSync(tmp, text, { mode: s.mode & 0o777 })
566
+ fs.renameSync(tmp, abs)
567
+ } catch (e) {
568
+ fs.rmSync(tmp, { force: true })
569
+ throw e
570
+ }
571
+ const now = fs.statSync(abs)
572
+ return { size: now.size, mtime: Math.floor(now.mtimeMs) }
573
+ }
574
+
487
575
  /** Show it in the file manager: the only way to open what cannot be shown here. */
488
576
  export const reveal = (abs) => shell.revealInFileManager(abs)
577
+
578
+ /** A folder is opened rather than pointed at: what you want is to be inside it. */
579
+ export const openFolder = (abs) => shell.openInFileManager(abs)
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'
@@ -25,7 +27,7 @@ import * as files from './files.js'
25
27
  import * as pdf from './pdf.js'
26
28
  import * as machine from './machine.js'
27
29
  import * as servers from './servers.js'
28
- import { report as platformReport } from '../platform/index.js'
30
+ import { report as platformReport, capabilities, why } from '../platform/index.js'
29
31
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
30
32
  // called, and two different readings of the same header would give two different names.
31
33
  import { matter, titleOf } from '../web/md.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(),
@@ -292,6 +341,11 @@ function site(req, res, seg) {
292
341
  // is a much smaller loss than that, so the header is gone.
293
342
  const SANDBOX = 'sandbox'
294
343
 
344
+ // How much a save is allowed to carry. Well under the megabyte the body reader stops at, which
345
+ // would otherwise refuse a large file with a message about requests instead of about the file —
346
+ // and far above anything you would actually edit in a browser tab: k0's own README is 56 KB.
347
+ const MAX_SAVE = 1 << 18
348
+
295
349
  // ── The recap of a window ────────────────────────────────────────────────────
296
350
  // Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
297
351
  // write-up to start, then asks every second whether it is done. Reading every repository's git
@@ -491,8 +545,11 @@ async function api(req, res, url) {
491
545
  const card = Number.isInteger(cardId) && cardId > 0 ? db.getCard(cardId) : null
492
546
  // Searching inside the files: the page asks for it as you type, and it needs neither the
493
547
  // listing nor git's state.
548
+ // `nerd` is the switch in the page: with it on, the configuration files are listed, so the
549
+ // search inside the text has to look in them too.
550
+ const nerd = url.searchParams.get('nerd') === '1'
494
551
  if (url.searchParams.get('only') === 'text') {
495
- return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '') })
552
+ return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '', nerd) })
496
553
  }
497
554
  const changed = await files.changed(root, card?.head_at_start)
498
555
  if (url.searchParams.get('only') === 'changed') return send(res, 200, { changed, now: Date.now() })
@@ -505,6 +562,13 @@ async function api(req, res, url) {
505
562
  truncated: all.truncated,
506
563
  changed,
507
564
  files: all.files,
565
+ // What this machine can do about a file, so the viewer greys the buttons out and says why
566
+ // rather than offering something that would quietly do nothing.
567
+ can: {
568
+ reveal: capabilities.revealInFileManager,
569
+ open: capabilities.openInFileManager,
570
+ whyNot: why('revealInFileManager') || why('openInFileManager'),
571
+ },
508
572
  now: Date.now(),
509
573
  })
510
574
  }
@@ -560,10 +624,27 @@ async function api(req, res, url) {
560
624
  return res.end(out)
561
625
  }
562
626
 
627
+ // One button, two neighbouring things: a file is pointed at inside its folder, a folder is
628
+ // opened. Which of the two is decided here rather than in the page, because the page would
629
+ // have to guess and the disk already knows.
563
630
  if (idRaw === 'reveal' && req.method === 'POST') {
564
- await files.reveal(abs)
631
+ if (fs.statSync(abs).isDirectory()) await files.openFolder(abs)
632
+ else await files.reveal(abs)
565
633
  return send(res, 200, { ok: true })
566
634
  }
635
+
636
+ // The first thing k0 writes into one of your repositories. Only configuration and notes, only
637
+ // a file that is already there, and only if nobody has touched it since the page read it.
638
+ if (idRaw === 'save' && req.method === 'POST') {
639
+ if (!files.isEditable(rel)) return send(res, 400, { error: 'This kind of file is not edited here' })
640
+ if (typeof body.text !== 'string') return send(res, 400, { error: 'Nothing to save' })
641
+ if (Buffer.byteLength(body.text) > MAX_SAVE) return send(res, 400, { error: 'That is too big to save from here' })
642
+ try {
643
+ return send(res, 200, files.write(abs, body.text, Number(body.mtime)))
644
+ } catch (e) {
645
+ return send(res, e.conflict ? 409 : 500, { error: e.message })
646
+ }
647
+ }
567
648
  if (!idRaw && req.method === 'GET') return send(res, 200, { path: rel, ...files.read(abs) })
568
649
  }
569
650
 
@@ -623,6 +704,7 @@ async function api(req, res, url) {
623
704
  const pid = readLiveSessions().get(card.session_id)?.pid
624
705
  const out = await closeTerminal({ winId: card.terminal_window_id, pid })
625
706
  db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
707
+ db.setAutoClosed(id, false) // this one was you: the card must not say otherwise
626
708
  tick() // so the answer already carries the closed card, without waiting for the loop
627
709
  return send(res, 200, { ...out, card: db.getCard(id) })
628
710
  }
@@ -642,6 +724,7 @@ async function api(req, res, url) {
642
724
  try {
643
725
  const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
644
726
  db.setTerminalWindow(id, out.winId)
727
+ db.setAutoClosed(id, false) // there is a window again: whoever shut the last one is history
645
728
  tick()
646
729
  return send(res, 200, { ...out, card: db.getCard(id) })
647
730
  } catch (err) {
@@ -694,6 +777,9 @@ http
694
777
  }
695
778
  })
696
779
  .listen(PORT, '127.0.0.1', () => {
780
+ // The settings file, written out with everything in it the first time k0 runs: it is the only
781
+ // list of what can be changed, so it has to exist before anybody goes looking for it.
782
+ settings.ensure()
697
783
  tick()
698
784
  // The mode is remembered, and at startup it puts the machine back as it was: the sleep
699
785
  // 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. */
@@ -383,10 +387,18 @@ body {
383
387
  gap: 7px;
384
388
  margin-bottom: 3px;
385
389
  }
390
+ /* The repository's name takes the room that is left, so everything after it — the git mark, the
391
+ memory chip — sits against the right edge of the note. The same shape as the heading of a
392
+ column, and for the same reason: hanging off the end of the name those marks landed in a
393
+ different place on every note, and a row of notes had no line to read down. */
386
394
  .postit .tag {
395
+ flex: 1;
387
396
  font: 500 1.05rem/1 var(--hand);
388
397
  color: rgba(0, 0, 0, .5);
389
398
  min-width: 0;
399
+ overflow: hidden;
400
+ text-overflow: ellipsis;
401
+ white-space: nowrap;
390
402
  }
391
403
  /* The name is the session's: it is in monospace like the k0 mark at the top.
392
404
  Monospace is heavier at the same size, which is why it drops to .94rem. */