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,40 @@
1
+ /**
2
+ * Reading a process table, the parts that are the same everywhere.
3
+ *
4
+ * macOS and Linux both have a `ps` that can be asked for exactly the five fields k0 wants,
5
+ * in exactly that order, so one parser serves both. Windows has no `ps`, but its adapter
6
+ * builds the same shape out of CIM data, which is why the shape and not the command is what
7
+ * lives here.
8
+ */
9
+
10
+ /**
11
+ * One `ps` line per process: pid, parent, resident memory in KB, CPU time consumed since it
12
+ * was born, and the command — which contains spaces, so it takes everything that is left.
13
+ */
14
+ export function parsePs(out) {
15
+ const procs = new Map()
16
+ for (const line of String(out ?? '').split('\n')) {
17
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)
18
+ if (!m) continue
19
+ procs.set(Number(m[1]), {
20
+ ppid: Number(m[2]),
21
+ rss: Number(m[3]) * 1024,
22
+ cpu: cpuSeconds(m[4]),
23
+ cmd: m[5],
24
+ })
25
+ }
26
+ return procs
27
+ }
28
+
29
+ /**
30
+ * CPU time the way `ps` writes it: `MM:SS.ss` usually, `HH:MM:SS` when it adds up, with a
31
+ * leading `DD-` for processes that have been up for days.
32
+ */
33
+ export function cpuSeconds(s) {
34
+ const m = /^(?:(\d+)-)?(\d+):(\d+)(?::(\d+))?(?:\.(\d+))?$/.exec(String(s ?? '').trim())
35
+ if (!m) return 0
36
+ const [, d, a, b, c, frac] = m
37
+ // Three groups means hours:minutes:seconds, two means minutes:seconds.
38
+ const base = c === undefined ? Number(a) * 60 + Number(b) : Number(a) * 3600 + Number(b) * 60 + Number(c)
39
+ return Number(d || 0) * 86400 + base + Number(`0.${frac || 0}`)
40
+ }
@@ -0,0 +1,99 @@
1
+ import { execFile, execFileSync } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ const DEFAULT_TIMEOUT = 5000
9
+
10
+ /**
11
+ * Run a command and get its stdout back.
12
+ *
13
+ * Everything that talks to the operating system goes through here rather than through a
14
+ * shell, so a directory name with a space or a quote in it stays a directory name instead
15
+ * of turning into two arguments.
16
+ */
17
+ export async function run(file, args = [], opts = {}) {
18
+ const { stdout } = await execFileAsync(file, args, { timeout: DEFAULT_TIMEOUT, ...opts })
19
+ return stdout
20
+ }
21
+
22
+ /** Same, but never throws: a command that is not there returns an empty string. */
23
+ export async function runQuiet(file, args = [], opts = {}) {
24
+ try {
25
+ return await run(file, args, opts)
26
+ } catch {
27
+ return ''
28
+ }
29
+ }
30
+
31
+ export function runSync(file, args = [], opts = {}) {
32
+ return execFileSync(file, args, { encoding: 'utf8', timeout: DEFAULT_TIMEOUT, ...opts })
33
+ }
34
+
35
+ export function runSyncQuiet(file, args = [], opts = {}) {
36
+ try {
37
+ return runSync(file, args, opts)
38
+ } catch {
39
+ return ''
40
+ }
41
+ }
42
+
43
+ const found = new Map()
44
+
45
+ /**
46
+ * Find an executable, preferring a known absolute path and falling back to PATH.
47
+ *
48
+ * The absolute path comes first for a reason that only shows up in production: the server
49
+ * runs as a background service, and a login shell's PATH is not what it inherits. Under
50
+ * launchd it is barely more than /usr/bin:/bin. Looking the binary up by name alone works
51
+ * perfectly from a terminal and then fails the moment k0 is installed for real.
52
+ *
53
+ * The reverse fallback matters too — Homebrew, Nix and the various Linux distributions all
54
+ * disagree about where things live — so this tries the known locations, then the PATH, and
55
+ * only then gives up.
56
+ *
57
+ * @param {string} name bare command name, e.g. 'git'
58
+ * @param {string[]} candidates absolute paths to try first
59
+ * @returns {string|null}
60
+ */
61
+ export function which(name, candidates = []) {
62
+ if (found.has(name)) return found.get(name)
63
+ const tried = [...candidates]
64
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
65
+ if (!dir) continue
66
+ for (const ext of process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']) {
67
+ tried.push(path.join(dir, name + ext))
68
+ }
69
+ }
70
+ for (const candidate of tried) {
71
+ try {
72
+ fs.accessSync(candidate, fs.constants.X_OK)
73
+ found.set(name, candidate)
74
+ return candidate
75
+ } catch {
76
+ /* keep looking */
77
+ }
78
+ }
79
+ found.set(name, null)
80
+ return null
81
+ }
82
+
83
+ /** `which`, but it throws a sentence a user can act on instead of ENOENT. */
84
+ export function need(name, candidates = [], hint = '') {
85
+ const bin = which(name, candidates)
86
+ if (bin) return bin
87
+ throw new Error(`k0 needs "${name}" but could not find it on this machine.${hint ? ` ${hint}` : ''}`)
88
+ }
89
+
90
+ /** Is this process still alive? Signal 0 asks without sending anything. */
91
+ export function alive(pid) {
92
+ if (!pid) return false
93
+ try {
94
+ process.kill(pid, 0)
95
+ return true
96
+ } catch (err) {
97
+ return err.code === 'EPERM'
98
+ }
99
+ }
@@ -0,0 +1,242 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { run, runQuiet, which } from './run.js'
3
+
4
+ /**
5
+ * A terminal, backed by tmux.
6
+ *
7
+ * This is the choice that makes everything else possible outside macOS. k0 does not just
8
+ * need to *start* a terminal — it needs to rename it, read back what is on its screen to
9
+ * know when Claude Code's interface is ready, and put a prompt in front of you without
10
+ * sending it. On macOS, AppleScript does all three. On Linux there is no equivalent: every
11
+ * terminal emulator is its own island, and most of them cannot be addressed at all once
12
+ * they are running.
13
+ *
14
+ * tmux can. It creates a session, renames it, captures the pane, sends literal keys without
15
+ * a newline, and kills it cleanly — and it does all of that identically on every emulator,
16
+ * because the emulator is reduced to a window that happens to be attached. Which also means
17
+ * a session survives its window being closed: you can reattach with `tmux attach -t <name>`
18
+ * and find your work exactly where you left it.
19
+ *
20
+ * What tmux cannot do is move, resize or restyle the window around it. That belongs to the
21
+ * window manager, and it is why `windows` and `font` are probed rather than assumed.
22
+ */
23
+
24
+ const TMUX = () => which('tmux')
25
+
26
+ /** Terminal emulators, in the order they are tried, and how each runs a command. */
27
+ const EMULATORS = [
28
+ { bin: 'gnome-terminal', args: (cmd) => ['--', ...cmd] },
29
+ { bin: 'konsole', args: (cmd) => ['-e', ...cmd] },
30
+ { bin: 'kitty', args: (cmd) => [...cmd] },
31
+ { bin: 'alacritty', args: (cmd) => ['-e', ...cmd] },
32
+ { bin: 'wezterm', args: (cmd) => ['start', '--', ...cmd] },
33
+ { bin: 'xfce4-terminal', args: (cmd) => ['-x', ...cmd] },
34
+ { bin: 'tilix', args: (cmd) => ['-e', ...cmd] },
35
+ { bin: 'x-terminal-emulator', args: (cmd) => ['-e', ...cmd] },
36
+ { bin: 'xterm', args: (cmd) => ['-e', ...cmd] },
37
+ ]
38
+
39
+ /**
40
+ * Which emulator to open. `K0_TERMINAL` wins, because no list of ours will ever cover every
41
+ * terminal anybody uses, and being wrong about it should not mean being stuck with it.
42
+ */
43
+ export function findEmulator() {
44
+ const forced = process.env.K0_TERMINAL
45
+ if (forced) {
46
+ const bin = which(forced)
47
+ if (bin) return { bin, args: (cmd) => ['-e', ...cmd], name: forced }
48
+ }
49
+ for (const e of EMULATORS) {
50
+ const bin = which(e.bin)
51
+ if (bin) return { ...e, bin, name: e.bin }
52
+ }
53
+ return null
54
+ }
55
+
56
+ const XDOTOOL = () => which('xdotool')
57
+ const WMCTRL = () => which('wmctrl')
58
+
59
+ /**
60
+ * Wayland deliberately does not let one application move another's windows, so there is no
61
+ * xdotool equivalent and there is not going to be. Under Wayland k0 says window placement is
62
+ * unavailable rather than silently doing nothing.
63
+ */
64
+ export const isWayland = () =>
65
+ (process.env.XDG_SESSION_TYPE || '').toLowerCase() === 'wayland' || !!process.env.WAYLAND_DISPLAY
66
+
67
+ export const canPlaceWindows = () => !isWayland() && (!!XDOTOOL() || !!WMCTRL())
68
+
69
+ let counter = 0
70
+ const sessionName = () => `k0-${process.pid}-${++counter}`
71
+
72
+ const exists = async (session) => {
73
+ const out = await runQuiet(TMUX(), ['has-session', '-t', `=${session}`]).catch(() => '')
74
+ // `has-session` says what it thinks through the exit code, and runQuiet swallows that, so
75
+ // ask for the list instead and look for the name.
76
+ const list = await runQuiet(TMUX(), ['list-sessions', '-F', '#{session_name}'])
77
+ return String(list).split('\n').includes(session) || !!out
78
+ }
79
+
80
+ export async function open({ command, title }) {
81
+ const tmux = TMUX()
82
+ if (!tmux) throw new Error('k0 needs tmux to drive terminals on this platform. Install it and try again.')
83
+ const session = sessionName()
84
+
85
+ // The session runs the command directly rather than a shell that then runs it: when Claude
86
+ // Code exits, the session ends, which is what makes a finished card's terminal disappear
87
+ // instead of leaving an idle shell behind.
88
+ await run(tmux, ['new-session', '-d', '-s', session, '-n', title || session, command])
89
+ // Off by default, and worth turning on: it is what puts the card's name in the window's
90
+ // title bar rather than "tmux".
91
+ await runQuiet(tmux, ['set-option', '-t', session, 'set-titles', 'on'])
92
+ await runQuiet(tmux, ['set-option', '-t', session, 'set-titles-string', title || session])
93
+
94
+ const emulator = findEmulator()
95
+ if (emulator) {
96
+ const child = spawn(emulator.bin, emulator.args([tmux, 'attach', '-t', session]), {
97
+ stdio: 'ignore',
98
+ detached: true,
99
+ })
100
+ child.unref()
101
+ }
102
+ return session
103
+ }
104
+
105
+ export async function setTitle(handle, title) {
106
+ if (!handle) return false
107
+ const tmux = TMUX()
108
+ if (!tmux) return false
109
+ const ok = await runQuiet(tmux, ['rename-window', '-t', `${handle}:0`, title])
110
+ await runQuiet(tmux, ['set-option', '-t', handle, 'set-titles-string', title])
111
+ return ok !== null
112
+ }
113
+
114
+ export async function readScreen(handle) {
115
+ if (!handle) return null
116
+ const tmux = TMUX()
117
+ if (!tmux) return null
118
+ try {
119
+ return await run(tmux, ['capture-pane', '-p', '-t', handle])
120
+ } catch {
121
+ return null // session gone
122
+ }
123
+ }
124
+
125
+ /**
126
+ * `send-keys -l` sends the text literally and adds nothing: no newline, no key interpretation.
127
+ * It is the closest thing to a paste there is, and unlike a real paste it needs no clipboard,
128
+ * no focus and no accessibility permission — which makes it more reliable here than the macOS
129
+ * path it replaces.
130
+ */
131
+ export async function paste(text, handle) {
132
+ const tmux = TMUX()
133
+ if (!tmux || !handle) return { pasted: false, error: 'no terminal session' }
134
+ try {
135
+ await run(tmux, ['send-keys', '-t', handle, '-l', text])
136
+ return { pasted: true }
137
+ } catch (err) {
138
+ return { pasted: false, error: String(err.stderr || err.message).trim() }
139
+ }
140
+ }
141
+
142
+ export async function type(text, handle) {
143
+ const tmux = TMUX()
144
+ if (!tmux || !handle) return { written: false }
145
+ try {
146
+ await run(tmux, ['send-keys', '-t', handle, '-l', text])
147
+ await run(tmux, ['send-keys', '-t', handle, 'Enter'])
148
+ return { written: true }
149
+ } catch (err) {
150
+ return { written: false, error: String(err.stderr || err.message).trim() }
151
+ }
152
+ }
153
+
154
+ export async function close({ handle, pid }) {
155
+ if (pid) {
156
+ try {
157
+ process.kill(pid, 'SIGTERM')
158
+ for (let i = 0; i < 20; i++) {
159
+ await new Promise((r) => setTimeout(r, 100))
160
+ try {
161
+ process.kill(pid, 0)
162
+ } catch {
163
+ break
164
+ }
165
+ }
166
+ } catch {
167
+ /* already dead, or not ours */
168
+ }
169
+ }
170
+ if (!handle) return { closed: false }
171
+ const tmux = TMUX()
172
+ if (!tmux) return { closed: false }
173
+ const before = await exists(handle)
174
+ await runQuiet(tmux, ['kill-session', '-t', handle])
175
+ return { closed: before }
176
+ }
177
+
178
+ /** The window showing this session, found by the title tmux set on it. */
179
+ async function windowId(handle) {
180
+ const xdotool = XDOTOOL()
181
+ if (!xdotool) return null
182
+ const out = await runQuiet(xdotool, ['search', '--name', handle])
183
+ return String(out).trim().split('\n').filter(Boolean).pop() || null
184
+ }
185
+
186
+ export async function focus(handle) {
187
+ if (!handle) return { ok: false, error: 'This card has no terminal of its own' }
188
+ if (!canPlaceWindows()) return { ok: false, error: 'k0 cannot raise windows on this desktop' }
189
+ const id = await windowId(handle)
190
+ if (!id) return { ok: false, error: 'That window is gone' }
191
+ await runQuiet(XDOTOOL(), ['windowactivate', id])
192
+ return { ok: true }
193
+ }
194
+
195
+ /**
196
+ * Geometry, best effort. There is no NSScreen here to ask for the usable area net of panels
197
+ * and docks, so the work area comes from the window manager's own `_NET_WORKAREA` by way of
198
+ * xdotool, and the same 86% margin as everywhere else is applied to it.
199
+ */
200
+ export async function relayout(handles) {
201
+ if (!canPlaceWindows()) return { touched: 0 }
202
+ const xdotool = XDOTOOL()
203
+ const geom = await runQuiet(xdotool, ['getdisplaygeometry'])
204
+ const [sw, sh] = String(geom).trim().split(/\s+/).map(Number)
205
+ if (!sw || !sh) return { touched: 0 }
206
+ const w = Math.round(sw * 0.86)
207
+ const h = Math.round(sh * 0.86)
208
+ const x = Math.round((sw - w) / 2)
209
+ const y = Math.round((sh - h) / 2)
210
+ let touched = 0
211
+ for (const handle of handles || []) {
212
+ const id = await windowId(handle)
213
+ if (!id) continue
214
+ await runQuiet(xdotool, ['windowmove', id, String(x), String(y)])
215
+ await runQuiet(xdotool, ['windowsize', id, String(w), String(h)])
216
+ touched++
217
+ }
218
+ return { touched }
219
+ }
220
+
221
+ /**
222
+ * Font size belongs to the emulator, and every emulator spells it differently — some only in
223
+ * a config file that is read at startup. There is no honest generic answer, so k0 says it
224
+ * cannot and driving mode changes only the board's own text.
225
+ */
226
+ export async function setFont() {
227
+ return { touched: 0 }
228
+ }
229
+
230
+ export async function defaultFontSize() {
231
+ return 12
232
+ }
233
+
234
+ export const capabilities = () => ({
235
+ windows: canPlaceWindows(),
236
+ font: false,
237
+ readScreen: !!TMUX(),
238
+ pasteWithoutSending: !!TMUX(),
239
+ title: !!TMUX(),
240
+ })
241
+
242
+ export { posixCommand as buildCommand } from './command.js'
@@ -0,0 +1,53 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import * as terminal from './terminal.js'
4
+ import * as power from './power.js'
5
+ import * as metrics from './metrics.js'
6
+ import * as shell from './shell.js'
7
+ import * as service from './service.js'
8
+ import { NO_CAPABILITIES } from '../contract.js'
9
+ import { which } from '../shared/run.js'
10
+
11
+ export const name = 'Windows'
12
+
13
+ export const trayScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'tray.ps1')
14
+
15
+ const hasPowerShell = () => !!(which('pwsh') || which('powershell'))
16
+
17
+ export const capabilities = {
18
+ ...NO_CAPABILITIES,
19
+ terminal: terminal.capabilities(),
20
+ power: power.capabilities(),
21
+ metrics: metrics.capabilities(),
22
+ service: service.capabilities(),
23
+ tray: hasPowerShell(),
24
+ revealInFileManager: true,
25
+ }
26
+
27
+ export const notes = {
28
+ ...power.notes,
29
+ ...metrics.notes,
30
+ 'terminal.readScreen':
31
+ 'Windows gives no way to read what is printed inside another process’s console, so k0 ' +
32
+ 'cannot tell exactly when Claude Code has finished starting: it waits a fixed moment ' +
33
+ 'instead. On a slow machine the prompt can arrive early — press Escape, then paste it ' +
34
+ 'again from the card.',
35
+ 'terminal.title':
36
+ 'Windows Terminal owns its tab titles, so renaming a card does not rename a window that ' +
37
+ 'is already open. The new name shows up next time the session starts.',
38
+ 'terminal.font':
39
+ 'Font size lives in your terminal’s own profile, which k0 will not rewrite, so driving ' +
40
+ 'mode enlarges the board but not the terminals.',
41
+ ...(hasPowerShell()
42
+ ? {}
43
+ : {
44
+ 'terminal.windows':
45
+ 'PowerShell was not found. k0 needs it to open, place and raise terminal windows on ' +
46
+ 'Windows.',
47
+ }),
48
+ }
49
+
50
+ export { terminal, power, metrics, shell, service }
51
+
52
+ /** Nothing on Windows reaches outside k0's own files, so there is nothing to consent to. */
53
+ export const extras = []
@@ -0,0 +1,72 @@
1
+ import os from 'node:os'
2
+ import { psJson } from './powershell.js'
3
+
4
+ /**
5
+ * Process and memory figures on Windows, out of CIM.
6
+ *
7
+ * There is no `ps`, so the shape the rest of k0 expects is built here instead of parsed. The
8
+ * fields line up one for one: WorkingSetSize is resident memory, and KernelModeTime plus
9
+ * UserModeTime is CPU time — in 100-nanosecond ticks, which is where the division comes from.
10
+ */
11
+
12
+ export async function processes() {
13
+ const rows = await psJson(
14
+ `@(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, WorkingSetSize, KernelModeTime, UserModeTime, CommandLine, Name)`,
15
+ []
16
+ )
17
+ const procs = new Map()
18
+ for (const row of Array.isArray(rows) ? rows : [rows].filter(Boolean)) {
19
+ const pid = Number(row.ProcessId)
20
+ if (!pid) continue
21
+ procs.set(pid, {
22
+ ppid: Number(row.ParentProcessId) || 0,
23
+ rss: Number(row.WorkingSetSize) || 0,
24
+ cpu: (Number(row.KernelModeTime || 0) + Number(row.UserModeTime || 0)) / 1e7,
25
+ cmd: row.CommandLine || row.Name || '',
26
+ })
27
+ }
28
+ return procs
29
+ }
30
+
31
+ /**
32
+ * Windows has no equivalent of macOS's wired/compressed split, so those come back zero and
33
+ * the whole of "used" is attributed to applications. `cached` is the standby list, which is
34
+ * the closest thing to file-backed pages — memory nobody is short of.
35
+ */
36
+ export async function memory() {
37
+ const info = await psJson(
38
+ `Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory`,
39
+ null
40
+ )
41
+ const total = info ? Number(info.TotalVisibleMemorySize) * 1024 : os.totalmem()
42
+ const free = info ? Number(info.FreePhysicalMemory) * 1024 : os.freemem()
43
+ const used = Math.max(0, total - free)
44
+ return { total, used, app: used, wired: 0, compressed: 0, cached: 0 }
45
+ }
46
+
47
+ /** The page file is Windows's swap. A machine with none reports zeroes, which is correct. */
48
+ export async function swap() {
49
+ const page = await psJson(
50
+ `Get-CimInstance Win32_PageFileUsage | Select-Object AllocatedBaseSize, CurrentUsage`,
51
+ null
52
+ )
53
+ if (!page) return { total: 0, used: 0 }
54
+ const rows = Array.isArray(page) ? page : [page]
55
+ return {
56
+ total: rows.reduce((n, r) => n + Number(r.AllocatedBaseSize || 0) * 1024 * 1024, 0),
57
+ used: rows.reduce((n, r) => n + Number(r.CurrentUsage || 0) * 1024 * 1024, 0),
58
+ }
59
+ }
60
+
61
+ /** Windows publishes no pressure verdict, so the board judges by how full memory is. */
62
+ export async function pressureLevel() {
63
+ return 1
64
+ }
65
+
66
+ export const capabilities = () => ({ pressure: false, swap: true })
67
+
68
+ export const notes = {
69
+ 'metrics.pressure':
70
+ 'Windows does not publish a memory-pressure verdict the way macOS and Linux do, so k0 ' +
71
+ 'judges by how full memory is instead.',
72
+ }
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { psJson, POWERSHELL } from './powershell.js'
3
+
4
+ /**
5
+ * Keeping a Windows machine awake.
6
+ *
7
+ * `SetThreadExecutionState` is the Windows equivalent of an idle assertion: a thread declares
8
+ * that the system (and optionally the display) is needed, and the declaration lasts exactly
9
+ * as long as that thread does. So k0 holds it in a small PowerShell process and kills that
10
+ * process to let go — the same shape as `caffeinate` on macOS and `systemd-inhibit` on Linux.
11
+ *
12
+ * The lid is not here. On Windows lid behaviour is a power-plan setting changed with
13
+ * `powercfg`, it applies to the whole machine, and it survives k0 being uninstalled. That is
14
+ * not something a board of sticky notes should rewrite on your behalf, so the switch is
15
+ * reported as unavailable with a note saying where to change it yourself.
16
+ */
17
+
18
+ const ES_CONTINUOUS = 0x80000000
19
+ const ES_SYSTEM_REQUIRED = 0x00000001
20
+ const ES_DISPLAY_REQUIRED = 0x00000002
21
+
22
+ let child = null
23
+ let activeLevel = null
24
+
25
+ export async function state() {
26
+ const battery = await psJson(
27
+ `Get-CimInstance Win32_Battery | Select-Object -First 1 EstimatedChargeRemaining, BatteryStatus`,
28
+ null
29
+ )
30
+ if (!battery) return { onAcPower: true, charge: null }
31
+ // BatteryStatus 2 is "AC power"; 1 is "discharging". Anything else is a charging state,
32
+ // which still means the mains are connected.
33
+ return {
34
+ onAcPower: Number(battery.BatteryStatus) !== 1,
35
+ charge: Number.isFinite(Number(battery.EstimatedChargeRemaining))
36
+ ? Number(battery.EstimatedChargeRemaining)
37
+ : null,
38
+ }
39
+ }
40
+
41
+ export function inhibit(level) {
42
+ if (activeLevel === level) return
43
+ release()
44
+ if (!level) return
45
+ const shell = POWERSHELL()
46
+ if (!shell) return
47
+ activeLevel = level
48
+
49
+ const flags =
50
+ ES_CONTINUOUS | ES_SYSTEM_REQUIRED | (level === 'display' ? ES_DISPLAY_REQUIRED : 0)
51
+
52
+ // The state is held for the lifetime of this process and released the moment it exits,
53
+ // including when it is killed outright — which is the safety net that stops a machine
54
+ // being left awake because k0 went away badly.
55
+ const script = `
56
+ Add-Type -Namespace K0 -Name Power -MemberDefinition @'
57
+ [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags);
58
+ '@
59
+ [K0.Power]::SetThreadExecutionState(${flags >>> 0}) | Out-Null
60
+ while ($true) { Start-Sleep -Seconds 3600 }`
61
+
62
+ const spawned = spawn(shell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
63
+ stdio: 'ignore',
64
+ })
65
+ child = spawned
66
+ const forget = () => {
67
+ if (child !== spawned) return
68
+ child = null
69
+ activeLevel = null
70
+ }
71
+ spawned.on('error', forget)
72
+ spawned.on('exit', forget)
73
+ spawned.unref()
74
+ }
75
+
76
+ export function release() {
77
+ activeLevel = null
78
+ if (!child) return
79
+ try {
80
+ child.kill()
81
+ } catch {
82
+ /* already gone */
83
+ }
84
+ child = null
85
+ }
86
+
87
+ export async function setLidSleepBlocked() {}
88
+
89
+ export async function lidSleepBlocked() {
90
+ return false
91
+ }
92
+
93
+ export function releaseSync() {
94
+ release()
95
+ }
96
+
97
+ export const capabilities = () => ({
98
+ keepAwake: !!POWERSHELL(),
99
+ keepDisplayAwake: !!POWERSHELL(),
100
+ lidSleep: false,
101
+ battery: !!POWERSHELL(),
102
+ })
103
+
104
+ export const notes = {
105
+ 'power.lidSleep':
106
+ 'On Windows the lid is a power-plan setting. To keep working with the lid closed, set ' +
107
+ '"When I close the lid" to "Do nothing" in Control Panel → Power Options. k0 will not ' +
108
+ 'change a machine-wide power setting for you.',
109
+ }
@@ -0,0 +1,44 @@
1
+ import { run, runQuiet, which } from '../shared/run.js'
2
+
3
+ /**
4
+ * PowerShell is how k0 talks to Windows.
5
+ *
6
+ * Everything Windows-specific here — window handles, process trees, execution state, the
7
+ * tray icon — is reachable from PowerShell without installing anything, which is the whole
8
+ * reason it is used rather than a native module. k0 has no dependencies and is not about to
9
+ * grow one that needs a compiler on the user's machine.
10
+ *
11
+ * PowerShell 7 (`pwsh`) is preferred where it exists and Windows PowerShell 5.1 is the
12
+ * fallback, because 5.1 is on every Windows install and the scripts here stay inside what
13
+ * both understand.
14
+ */
15
+
16
+ export const POWERSHELL = () => which('pwsh') || which('powershell')
17
+
18
+ const BASE_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']
19
+
20
+ export const ps = (script, opts = {}) => run(POWERSHELL(), [...BASE_ARGS, script], opts)
21
+
22
+ export const psQuiet = (script, opts = {}) => runQuiet(POWERSHELL(), [...BASE_ARGS, script], opts)
23
+
24
+ /**
25
+ * Ask PowerShell for JSON and get an object back.
26
+ *
27
+ * `-Depth 4` is not decoration: ConvertTo-Json truncates at depth 2 by default and silently
28
+ * turns anything deeper into the string "System.Object[]". `-AsArray`-less output also
29
+ * collapses a one-element list into a bare object, so single results are wrapped by the
30
+ * caller rather than trusted to arrive as a list.
31
+ */
32
+ export async function psJson(script, fallback = null) {
33
+ const out = await psQuiet(`${script} | ConvertTo-Json -Depth 4 -Compress`)
34
+ const text = String(out).trim()
35
+ if (!text) return fallback
36
+ try {
37
+ return JSON.parse(text)
38
+ } catch {
39
+ return fallback
40
+ }
41
+ }
42
+
43
+ /** Single-quote a string for a PowerShell literal. */
44
+ export const psq = (s) => `'${String(s).replace(/'/g, "''")}'`