k0-board 0.1.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.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
@@ -0,0 +1,121 @@
1
+ import { readLiveSessions } from './watcher.js'
2
+ import { isDriving } from './mode.js'
3
+ import { titleCase } from '../web/title.js'
4
+ import { terminal, shell, capabilities } from '../platform/index.js'
5
+
6
+ /**
7
+ * "fix now 2h" -> "Fix-Now-2h", which is the name you will see on the session.
8
+ * Same rule as the title field on the dashboard: the session's name is exactly what you saw
9
+ * yourself typing.
10
+ */
11
+ export function sessionName(title) {
12
+ return titleCase(title).replace(/-+$/, '') || 'Untitled'
13
+ }
14
+
15
+ export const findClaude = () => shell.findClaude()
16
+
17
+ /** In driving mode the terminal has to be readable from across the room. */
18
+ const DRIVING_FONT_SIZE = 22
19
+
20
+ /** How big the text has to be right now: it depends only on the mode in force. */
21
+ const fontSize = () => (isDriving() ? Promise.resolve(DRIVING_FONT_SIZE) : terminal.defaultFontSize())
22
+
23
+ export async function setTerminalFont(handles) {
24
+ return terminal.setFont(handles, await fontSize())
25
+ }
26
+
27
+ export const relayoutWindows = (handles) => terminal.relayout(handles)
28
+ export const setWindowTitle = (handle, title) => terminal.setTitle(handle, title)
29
+ export const focusWindow = (handle) => terminal.focus(handle)
30
+ export const closeTerminal = ({ winId, pid }) => terminal.close({ handle: winId, pid })
31
+
32
+ /** Waits for Claude to really be up: the session shows up in ~/.claude/sessions. */
33
+ async function waitForSession(sessionId, timeoutMs = 30000) {
34
+ const deadline = Date.now() + timeoutMs
35
+ while (Date.now() < deadline) {
36
+ if (readLiveSessions().has(sessionId)) return true
37
+ await new Promise((r) => setTimeout(r, 300))
38
+ }
39
+ return false
40
+ }
41
+
42
+ /**
43
+ * The session file appears before the interface is ready to receive: writing at that moment
44
+ * loses the first characters and swallows the Enter. So we wait until the input box is
45
+ * actually visible in the window.
46
+ *
47
+ * Where the platform cannot read a terminal's screen — Windows — there is nothing to look at,
48
+ * and the only honest thing left is to wait a fixed moment. It is a guess, and the interface
49
+ * says so: `capabilities.terminal.readScreen` is false there, and the board explains what
50
+ * that costs.
51
+ */
52
+ async function waitForPrompt(handle, timeoutMs = 15000) {
53
+ if (!capabilities.terminal.readScreen) {
54
+ await new Promise((r) => setTimeout(r, 3500))
55
+ return false
56
+ }
57
+ if (!handle) return false
58
+ const deadline = Date.now() + timeoutMs
59
+ while (Date.now() < deadline) {
60
+ const screen = await terminal.readScreen(handle)
61
+ if (screen === null) return false // window gone
62
+ if (screen.includes('❯')) {
63
+ await new Promise((r) => setTimeout(r, 400)) // a moment for the first paint
64
+ return true
65
+ }
66
+ await new Promise((r) => setTimeout(r, 300))
67
+ }
68
+ return false
69
+ }
70
+
71
+ /**
72
+ * Starts (or resumes) a card's session.
73
+ * mode: 'start' assigns a new session id, 'resume' reopens the existing one.
74
+ */
75
+ export async function launch({ card, sessionId, mode = 'start' }) {
76
+ const name = sessionName(card.title)
77
+ const bin = findClaude()
78
+ const resume = mode === 'resume'
79
+ const args = []
80
+ if (resume) {
81
+ // Resuming forces nothing: that session already had a mode, and it is not our place to
82
+ // change it.
83
+ args.push('--dangerously-skip-permissions', '--resume', sessionId)
84
+ } else {
85
+ // A new session is born in plan mode: that is how work happens here, and turning it on by
86
+ // hand at every start was the one thing standing between you and beginning.
87
+ //
88
+ // The permissions flag has to change here, and it is not a preference: it is provable that
89
+ // `--dangerously-skip-permissions` **switches off** plan mode — whatever order you write
90
+ // them in, the session starts in `bypassPermissions` and there is no sign of a plan. The
91
+ // two flags do not error together, and that is the trap: it looks like it works.
92
+ // `--allow-dangerously-skip-permissions` instead keeps "stop asking me" within reach of
93
+ // shift+tab without imposing it from the start, and it is the only way to really begin
94
+ // inside a plan. The price is that once the plan is approved it asks for confirmations.
95
+ args.push('--allow-dangerously-skip-permissions', '--permission-mode', 'plan', '--session-id', sessionId)
96
+ }
97
+ args.push('-n', name)
98
+ // With "send it for me" the prompt goes straight to the CLI and starts without touching
99
+ // anything.
100
+ const autoSend = mode === 'start' && card.auto_send && card.prompt?.trim()
101
+ if (autoSend) args.push(card.prompt.trim())
102
+
103
+ const command = terminal.buildCommand({ cwd: card.project_path, bin, args })
104
+ const winId = await terminal.open({ command, title: name, fontSize: await fontSize() })
105
+ const up = await waitForSession(sessionId)
106
+
107
+ if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
108
+ if (!up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
109
+
110
+ await waitForPrompt(winId)
111
+
112
+ if (capabilities.terminal.pasteWithoutSending) {
113
+ const res = await terminal.paste(card.prompt.trim(), winId)
114
+ if (res.pasted) return { name, up, winId, ...res }
115
+ }
116
+
117
+ // Pasting is unavailable or was refused — on macOS that means no Accessibility permission.
118
+ // We type it instead, which sends it.
119
+ const fallback = await terminal.type(card.prompt.trim(), winId)
120
+ return { name, up, winId, pasted: false, autoSent: fallback.written, ...fallback }
121
+ }
@@ -0,0 +1,213 @@
1
+ import os from 'node:os'
2
+ import { metrics } from '../platform/index.js'
3
+
4
+ export { parsePs, cpuSeconds } from '../platform/shared/ps.js'
5
+
6
+ // ── What all of this costs ────────────────────────────────────────────────────
7
+ // The only place that looks at the machine's processes and memory. It answers two questions:
8
+ // is the computer struggling, and who is making it struggle.
9
+ //
10
+ // The honest number for "what does this session cost" is not the `claude` process: it is its
11
+ // whole subtree. A 200 MB claude can have an MCP server under it with Chrome behind that, and
12
+ // a 200 MB `tsc` started from a commit hook. That is where the memory goes, and it is what no
13
+ // other tool can attribute to the right card — Activity Monitor has never heard of cards.
14
+ //
15
+ // Summing the resident memory of a subtree counts shared memory twice between parent and
16
+ // children, so the total is a little generous. It is the usual approximation, and it is the
17
+ // right one for the question that matters here: which one do I close to feel better.
18
+
19
+ const TTL = 3000 // how often the sample is retaken, while somebody is watching
20
+ const ATTENTION = 15000 // how long a glance counts for before it goes quiet again
21
+ const CORES = os.cpus().length
22
+
23
+ /**
24
+ * Reading the process table costs little on a calm machine and a lot on a struggling one —
25
+ * measured: 30 ms to 360 ms — and struggling is exactly when you look at it. So it does not
26
+ * sit on the one-second loop, and it stops by itself as soon as nobody is looking.
27
+ */
28
+ let watching = 0
29
+ export function touch() {
30
+ watching = Date.now()
31
+ // On the first glance there is nothing to say yet: read straight away rather than leave the
32
+ // bar empty for the three seconds until the next round.
33
+ if (!snap) cycle()
34
+ }
35
+
36
+ let snap = null // { at, procs, kids, share, cpu, mem, swap, pressure }
37
+ let prev = null // { at, cpu: Map<pid, seconds> } — the previous sample, for the differences
38
+ let running = false
39
+
40
+ /** Who descends from whom: the map needed to walk the subtrees. */
41
+ export function childrenOf(procs) {
42
+ const kids = new Map()
43
+ for (const [pid, p] of procs) {
44
+ if (p.ppid === pid) continue // does not happen, but a loop here would spin forever
45
+ if (!kids.has(p.ppid)) kids.set(p.ppid, [])
46
+ kids.get(p.ppid).push(pid)
47
+ }
48
+ return kids
49
+ }
50
+
51
+ /** Every pid from `root` down, itself included. With the guard against cycles. */
52
+ export function subtree(kids, root, seen = new Set()) {
53
+ if (seen.has(root)) return seen
54
+ seen.add(root)
55
+ for (const k of kids.get(root) ?? []) subtree(kids, k, seen)
56
+ return seen
57
+ }
58
+
59
+ /**
60
+ * The verdict is not ours to invent: on macOS and Linux the kernel publishes its own
61
+ * judgement — 1 normal, 2 warning, 4 critical. One rule is added to it, memory nearly gone,
62
+ * because that is the moment before the kernel notices. Swap deliberately does NOT enter the
63
+ * verdict: on macOS the swap file grows by itself, so "how full is it" does not mean much.
64
+ * How much is in use is read anyway and shown on hover: it is the number that explains the
65
+ * stutters.
66
+ *
67
+ * Where the kernel publishes nothing — Windows — the level is always 1 and the memory rule is
68
+ * the whole verdict.
69
+ */
70
+ export function verdict(level, mem) {
71
+ if (level >= 4) return 'bad'
72
+ if (level >= 2 || (mem.total > 0 && mem.used / mem.total >= 0.9)) return 'warn'
73
+ return 'ok'
74
+ }
75
+
76
+ // ── The round ─────────────────────────────────────────────────────────────────
77
+ async function cycle() {
78
+ if (running || Date.now() - watching > ATTENTION) return
79
+ running = true
80
+ try {
81
+ const [procs, mem, sw, level] = await Promise.all([
82
+ metrics.processes(),
83
+ metrics.memory(),
84
+ metrics.swap(),
85
+ metrics.pressureLevel(),
86
+ ])
87
+ const at = Date.now()
88
+
89
+ // The CPU right now, not the average since the process was born: that second one is what
90
+ // `ps` reports as `%cpu`, and on an old process it says nothing about this minute. Here we
91
+ // look at how much CPU time passed between two samples.
92
+ const seconds = prev ? (at - prev.at) / 1000 : 0
93
+ const share = new Map()
94
+ let busy = 0
95
+ if (seconds > 0.2) {
96
+ for (const [pid, p] of procs) {
97
+ const was = prev.cpu.get(pid)
98
+ if (was === undefined) continue // born after the last sample: it has no before
99
+ const used = Math.max(0, p.cpu - was) / seconds
100
+ share.set(pid, used)
101
+ busy += used
102
+ }
103
+ }
104
+
105
+ prev = { at, cpu: new Map([...procs].map(([pid, p]) => [pid, p.cpu])) }
106
+ snap = {
107
+ at,
108
+ procs,
109
+ kids: childrenOf(procs),
110
+ share,
111
+ // Without a previous sample the CPU is not known yet: better to say so than invent it.
112
+ cpu: seconds > 0.2 ? Math.min(1, busy / CORES) : null,
113
+ mem,
114
+ swap: sw,
115
+ pressure: verdict(level, mem),
116
+ }
117
+ } catch {
118
+ /* one bad reading does not switch off what was already known */
119
+ } finally {
120
+ running = false
121
+ }
122
+ }
123
+ setInterval(cycle, TTL).unref()
124
+
125
+ // ── What is known right now ───────────────────────────────────────────────────
126
+ /**
127
+ * What a session weighs: its whole subtree. `top` is the three biggest processes inside it,
128
+ * and that is the answer to "why is this terminal eating so much".
129
+ */
130
+ export function loadOf(pid) {
131
+ if (!snap || !pid || !snap.procs.has(pid)) return null
132
+ const pids = subtree(snap.kids, pid)
133
+ let rss = 0
134
+ let cpu = 0
135
+ // By name, not by process: Chrome opens ten of them and seeing them listed one by one says
136
+ // nothing, while "Google Chrome 562 MB" says everything.
137
+ const byName = new Map()
138
+ for (const p of pids) {
139
+ const proc = snap.procs.get(p)
140
+ if (!proc) continue
141
+ rss += proc.rss
142
+ cpu += snap.share.get(p) ?? 0
143
+ const name = shortName(proc.cmd)
144
+ byName.set(name, (byName.get(name) ?? 0) + proc.rss)
145
+ }
146
+ return { rss, cpu: snap.cpu === null ? null : cpu / CORES, procs: pids.size, top: heaviest(byName) }
147
+ }
148
+
149
+ /**
150
+ * How the machine is doing, and what is taking it up that is not a k0 session. `others` exists
151
+ * so k0 does not take blame that belongs to Chrome: with swap full, the sessions on their own
152
+ * often explain nothing.
153
+ */
154
+ export function overview(sessionPids = []) {
155
+ if (!snap) return null
156
+ const mine = new Set()
157
+ for (const pid of sessionPids) if (snap.procs.has(pid)) for (const p of subtree(snap.kids, pid)) mine.add(p)
158
+
159
+ const byName = new Map()
160
+ for (const [pid, proc] of snap.procs) {
161
+ if (mine.has(pid)) continue
162
+ const name = shortName(proc.cmd)
163
+ byName.set(name, (byName.get(name) ?? 0) + proc.rss)
164
+ }
165
+
166
+ return {
167
+ cpu: snap.cpu,
168
+ mem: snap.mem,
169
+ swap: snap.swap,
170
+ pressure: snap.pressure,
171
+ others: heaviest(byName),
172
+ at: snap.at,
173
+ }
174
+ }
175
+
176
+ /** The three heaviest names, biggest first. */
177
+ const heaviest = (byName) =>
178
+ [...byName]
179
+ .map(([name, rss]) => ({ name, rss }))
180
+ .sort((a, b) => b.rss - a.rss)
181
+ .slice(0, 3)
182
+
183
+ /**
184
+ * A name worth showing, worked out from the command line. macOS applications first, which
185
+ * carry their real name in the bundle path — and it is also the only way to get them right,
186
+ * given that "Google Chrome" has a space in it. Then the interpreters, which on their own
187
+ * would only ever say "node": there, the name is the script they are running.
188
+ */
189
+ export function shortName(cmd) {
190
+ const app = /\/([^/]+)\.app\//.exec(cmd)
191
+ if (app) return app[1]
192
+
193
+ const argv = String(cmd ?? '').trim().split(/\s+/)
194
+ const base = (s) => (s || '').split(/[/\\]/).pop()
195
+ const first = base(argv[0]).replace(/\.exe$/i, '')
196
+ if (!RUNNERS.has(first)) return first || 'unknown'
197
+
198
+ // `npm exec chrome-devtools-mcp@latest --flag` → chrome-devtools-mcp
199
+ for (const arg of argv.slice(1)) {
200
+ if (arg.startsWith('-') || VERBS.has(arg)) continue
201
+ const name = base(arg).replace(/@[^@]*$/, '')
202
+ // Only if it looks like a program name: behind `node -e` there is code, and a fragment of
203
+ // code as a name would be worse than "node".
204
+ return /^[\w.@-]+$/.test(name) ? name : first
205
+ }
206
+ return first
207
+ }
208
+
209
+ const RUNNERS = new Set([
210
+ 'node', 'npm', 'npx', 'python', 'python3', 'ruby', 'sh', 'bash', 'zsh', 'deno', 'bun',
211
+ 'powershell', 'pwsh', 'cmd',
212
+ ])
213
+ const VERBS = new Set(['exec', 'run', 'run-script', '-c', 'start', 'test'])
package/server/mode.js ADDED
@@ -0,0 +1,182 @@
1
+ import * as db from './db.js'
2
+ import { power, capabilities } from '../platform/index.js'
3
+
4
+ // ── The four modes ────────────────────────────────────────────────────────────
5
+ // How awake the machine has to stay while sessions are working. A four-step scale, not two
6
+ // switches: because the two things were never independent. In driving mode the display MUST
7
+ // stay on — there is no point watching the board from across the room on a screen that turns
8
+ // itself off — and sitting in front of it programming is a third case that two booleans had
9
+ // no way to express.
10
+ //
11
+ // sleep the machine sleeps as it normally would
12
+ // away it does not sleep any more, not even with the lid closed; the display still does
13
+ // nerd you are sitting there: the display stays on too
14
+ // driving like nerd, plus everything set large — board and terminals
15
+ //
16
+ // Each line includes the one above it: that is what makes the four buttons a scale rather
17
+ // than a list. The only difference between nerd and driving is the size of the text.
18
+ //
19
+ // There are two levers, and an idle inhibitor alone is not enough on macOS: its assertions
20
+ // are all *idle* assertions, which stop sleep from inactivity but not the sleep that fires
21
+ // when the lid closes. That one takes a system flag and root. On Linux and Windows the lid
22
+ // belongs to a machine-wide setting that k0 refuses to rewrite on your behalf — see the notes
23
+ // in the platform adapters — so there the lid switch reports itself unavailable rather than
24
+ // promising something it will not deliver.
25
+
26
+ /**
27
+ * What each mode means, in one place: anyone who wants to know what a mode does reads it
28
+ * from here, and nobody works it out again for themselves.
29
+ *
30
+ * `inhibit` is the level handed to the platform: 'system' stops idle sleep, 'display' also
31
+ * keeps the screen on. The difference between away and nerd is exactly that word.
32
+ */
33
+ export const MODES = {
34
+ sleep: { inhibit: null, lid: false, driving: false },
35
+ away: { inhibit: 'system', lid: true, driving: false },
36
+ nerd: { inhibit: 'display', lid: true, driving: false },
37
+ driving: { inhibit: 'display', lid: true, driving: true },
38
+ }
39
+
40
+ /** Sleepiest to most awake: this is the order of the buttons on the board. */
41
+ export const ORDER = ['sleep', 'away', 'nerd', 'driving']
42
+
43
+ /** The one to start from, and the one to fall back to when an unknown word arrives. */
44
+ export const DEFAULT = 'away'
45
+
46
+ /** The key in `pref`, and the two it grew out of as k0 changed its mind about names. */
47
+ export const KEY = 'mode'
48
+ export const LEGACY_KEYS = ['modo', 'caffe']
49
+
50
+ /** Values written by older versions, and what they mean now. */
51
+ const LEGACY_VALUES = { sonno: 'sleep', caffe: 'away', nerd: 'nerd', driving: 'driving' }
52
+
53
+ /**
54
+ * Below this charge, and only on battery, k0 lets go of the lid. A machine forbidden to sleep
55
+ * does not merely drain: it reaches zero and dies outright, which is worse than a session
56
+ * left waiting. The idle inhibitor stays — that one is harmless — and everything resumes by
57
+ * itself as soon as the power is back.
58
+ */
59
+ export const THRESHOLD = 15
60
+
61
+ let mode = DEFAULT
62
+ let lidHeld = false // is the lid block really in force RIGHT NOW
63
+ let supply = { onAcPower: true, charge: null } // the power as it was last looked at
64
+ let stopped = false // the kettle is already off the hob (shutting down)
65
+
66
+ // ── Read, do not imagine ──────────────────────────────────────────────────────
67
+
68
+ /** A word that is not a mode must not blow anything up: it falls back to the default. */
69
+ export const normalise = (m) => {
70
+ const s = String(m)
71
+ if (Object.hasOwn(MODES, s)) return s
72
+ if (Object.hasOwn(LEGACY_VALUES, s)) return LEGACY_VALUES[s]
73
+ return DEFAULT
74
+ }
75
+
76
+ /**
77
+ * Whether the lid block belongs on or off: the whole rule, in one place. The mode asks for it,
78
+ * except on a nearly flat battery — and except where the platform cannot do it at all.
79
+ *
80
+ * `supported` defaults to what this machine can do and is passed explicitly by the tests, so the
81
+ * rule can be checked on any platform rather than only on the one that happens to support it.
82
+ */
83
+ export function lidWanted({ mode: m, onAcPower, charge, supported = capabilities.power.lidSleep }) {
84
+ if (!supported) return false
85
+ if (!MODES[normalise(m)].lid) return false
86
+ if (onAcPower || charge === null) return true
87
+ return charge >= THRESHOLD
88
+ }
89
+
90
+ // ── What the rest of the house knows ─────────────────────────────────────────
91
+
92
+ export const current = () => mode
93
+ export const lid = () => lidHeld
94
+
95
+ /**
96
+ * Whether the text goes large. `launcher.js` asks for every window that is born, and it is
97
+ * the only thing driving mode needs the outside world to know.
98
+ */
99
+ export const isDriving = () => MODES[mode].driving
100
+
101
+ /**
102
+ * Why we are in the state we are in. The board needs it to say so in words above the lit
103
+ * button: a mode that promises the lid closed and cannot deliver has to be able to say so,
104
+ * otherwise it is promising something it does not do.
105
+ */
106
+ export function reason() {
107
+ if (mode === 'sleep') return 'sleep'
108
+ if (lidHeld) return 'full'
109
+ if (!capabilities.power.lidSleep) return 'unsupported'
110
+ if (!lidWanted({ mode, ...supply })) return 'battery'
111
+ return 'no-permission'
112
+ }
113
+
114
+ /**
115
+ * Brings the world into line with the mode. At the end it reads back how it actually turned
116
+ * out instead of trusting the command it just gave: what matters is what the machine is
117
+ * doing, not what we believed we had told it.
118
+ */
119
+ export async function apply() {
120
+ supply = await power.state()
121
+ power.inhibit(MODES[mode].inhibit)
122
+ await power.setLidSleepBlocked(lidWanted({ mode, ...supply }))
123
+ lidHeld = await power.lidSleepBlocked()
124
+ }
125
+
126
+ export async function setMode(m) {
127
+ mode = normalise(m)
128
+ db.setPref(KEY, mode)
129
+ await apply()
130
+ }
131
+
132
+ /**
133
+ * At server startup. Puts the machine back the way you left it — driving included, which it
134
+ * used not to remember — and repairs the awkward case: mode `sleep` but the lid block left at
135
+ * 1 by a server that died badly. Putting it back to 0 is part of the job, otherwise the
136
+ * machine never sleeps again and nobody knows why.
137
+ */
138
+ export async function start() {
139
+ migrateLegacyPref()
140
+ mode = normalise(db.getPref(KEY, DEFAULT))
141
+ await apply()
142
+ }
143
+
144
+ /**
145
+ * k0 has renamed this preference twice: `caffe` when it was a single on/off switch, then
146
+ * `modo` when it became a scale, and now `mode`. Each old key is read once and retired, so an
147
+ * upgraded k0 does not start over from the default.
148
+ */
149
+ function migrateLegacyPref() {
150
+ for (const legacy of LEGACY_KEYS) {
151
+ const value = db.getPref(legacy, null)
152
+ if (value === null) continue
153
+ if (db.getPref(KEY, null) === null) {
154
+ // The oldest key held '1' or '0' rather than a mode name.
155
+ db.setPref(KEY, value === '0' ? 'sleep' : value === '1' ? 'away' : normalise(value))
156
+ }
157
+ db.dropPref(legacy)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * A check-up round, which the server's `tick` calls once a minute. It does two things: follow
163
+ * the battery down (and the power coming back), and put the inhibitor back up if something
164
+ * killed it from outside.
165
+ */
166
+ export async function guard() {
167
+ if (stopped) return
168
+ await apply()
169
+ }
170
+
171
+ /**
172
+ * The last gesture on the way out. Synchronous on purpose: from `process.on('exit')` nothing
173
+ * asynchronous gets to start any more, and this is the net that stops a machine being left
174
+ * awake forever because k0 went away while it was on. The preference is left alone: next
175
+ * time, `start()` puts everything back.
176
+ */
177
+ export function stopNow() {
178
+ if (stopped) return
179
+ stopped = true
180
+ power.releaseSync()
181
+ lidHeld = false
182
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * Where k0 keeps the things that must outlive a reinstall.
8
+ *
9
+ * Until 0.1.0 the database sat next to the source, which was fine while k0 only ever ran
10
+ * from a checkout. It stopped being fine the moment `npx k0-board` became the normal way
11
+ * in: npx unpacks into a cache directory that npm is free to wipe, so anything written
12
+ * there is on borrowed time. Data now lives in a directory owned by the user, and the
13
+ * installed copy of the app is disposable.
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
19
+ *
20
+ * On Windows the same tree goes under %LOCALAPPDATA%\k0, which is the local-only,
21
+ * not-roamed location Windows expects for this kind of state.
22
+ */
23
+ export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
24
+
25
+ const DEFAULT_HOME =
26
+ process.platform === 'win32'
27
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'k0')
28
+ : path.join(os.homedir(), '.k0')
29
+
30
+ export const HOME = process.env.K0_HOME ? path.resolve(process.env.K0_HOME) : DEFAULT_HOME
31
+
32
+ export const APP_DIR = path.join(HOME, 'app')
33
+ export const LOG_DIR = path.join(HOME, 'logs')
34
+ export const CACHE_DIR = path.join(HOME, 'cache')
35
+
36
+ /** `K0_DB` is how the tests stay away from the real board. See the guard in db.js. */
37
+ export const DB_PATH = process.env.K0_DB || path.join(HOME, 'k0.db')
38
+
39
+ export function ensureDirs() {
40
+ for (const dir of [HOME, LOG_DIR, CACHE_DIR]) fs.mkdirSync(dir, { recursive: true })
41
+ }
42
+
43
+ /**
44
+ * Move a pre-0.1.0 database out of the checkout and into the home directory.
45
+ *
46
+ * Only ever runs once, and only when there is nothing to overwrite: if the new location
47
+ * already holds a database, the old file is left alone rather than merged or clobbered.
48
+ * The write-ahead log and shared-memory files come along, because leaving them behind
49
+ * would silently drop whatever had not been checkpointed yet.
50
+ *
51
+ * And only ever into the DEFAULT home. Pointing `K0_HOME` somewhere else is how you run a
52
+ * second k0 against a scratch directory, and a move is not undoable by the person watching it
53
+ * happen: doing it there would drag the real board into a temporary folder while the real
54
+ * server still had it open. Verified the hard way.
55
+ */
56
+ export function migrateLegacyDatabase() {
57
+ if (process.env.K0_DB) return false
58
+ if (HOME !== DEFAULT_HOME) return false
59
+ const legacy = path.join(ROOT, 'k0.db')
60
+ if (!fs.existsSync(legacy) || fs.existsSync(DB_PATH)) return false
61
+ ensureDirs()
62
+ for (const suffix of ['', '-wal', '-shm']) {
63
+ const from = legacy + suffix
64
+ if (fs.existsSync(from)) fs.renameSync(from, DB_PATH + suffix)
65
+ }
66
+ return true
67
+ }