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,134 @@
1
+ /**
2
+ * The line between k0 and the operating system.
3
+ *
4
+ * Everything above this file — the board, the watcher, the API — is the same on every
5
+ * machine. Everything below it is not: opening a terminal window is AppleScript on macOS,
6
+ * tmux on Linux and Windows Terminal on Windows, and none of those three agree on what a
7
+ * "window" even is. Rather than sprinkle `process.platform` checks through the server, all
8
+ * of it goes behind the small surface described here, and each platform fills it in.
9
+ *
10
+ * Two rules keep this honest.
11
+ *
12
+ * **An adapter never pretends.** If a platform cannot do something — read back what is on a
13
+ * terminal's screen, stop the machine sleeping when the lid closes — it says so in
14
+ * `capabilities` and the feature is visibly switched off in the interface. A button that
15
+ * quietly does nothing is worse than a button that is greyed out with a reason next to it.
16
+ *
17
+ * **Capabilities are read at runtime, not guessed from the platform name.** Whether window
18
+ * geometry works on Linux depends on X11 versus Wayland, not on "linux"; whether Windows can
19
+ * read a terminal's contents depends on whether WSL is installed. Adapters probe.
20
+ *
21
+ * @typedef {object} Capabilities
22
+ * @property {object} terminal
23
+ * @property {boolean} terminal.windows can place, size and focus windows
24
+ * @property {boolean} terminal.font can change the font size of a live terminal
25
+ * @property {boolean} terminal.readScreen can read back what is on screen
26
+ * @property {boolean} terminal.pasteWithoutSending can leave a prompt sitting unsent
27
+ * @property {boolean} terminal.title can rename a window from outside
28
+ * @property {object} power
29
+ * @property {boolean} power.keepAwake can stop idle sleep
30
+ * @property {boolean} power.keepDisplayAwake can stop the display sleeping
31
+ * @property {boolean} power.lidSleep can stop sleep-on-lid-close
32
+ * @property {boolean} power.battery can read charge and power source
33
+ * @property {object} metrics
34
+ * @property {boolean} metrics.pressure the kernel publishes a memory-pressure verdict
35
+ * @property {boolean} metrics.swap swap usage is readable
36
+ * @property {object} service
37
+ * @property {boolean} service.autostart can register a service that starts at login
38
+ * @property {boolean} tray a menu bar / system tray icon is available
39
+ * @property {boolean} revealInFileManager
40
+ */
41
+
42
+ /**
43
+ * What an adapter that can do nothing would look like. Adapters spread this and override,
44
+ * so a capability added here later defaults to "no" everywhere instead of crashing.
45
+ */
46
+ export const NO_CAPABILITIES = {
47
+ terminal: { windows: false, font: false, readScreen: false, pasteWithoutSending: false, title: false },
48
+ power: { keepAwake: false, keepDisplayAwake: false, lidSleep: false, battery: false },
49
+ metrics: { pressure: false, swap: false },
50
+ service: { autostart: false },
51
+ tray: false,
52
+ revealInFileManager: false,
53
+ }
54
+
55
+ /**
56
+ * The reason a capability is missing, in a sentence the board can show to the user.
57
+ * Keys are dotted capability paths, e.g. `terminal.readScreen`.
58
+ * @typedef {Record<string, string>} CapabilityNotes
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} TerminalAdapter
63
+ * @property {(opts: {command: string, title: string}) => Promise<string|null>} open
64
+ * Opens a terminal running `command` and returns an opaque handle, or null if the
65
+ * platform has no notion of a handle it can address later.
66
+ * @property {(handle: string, title: string) => Promise<boolean>} setTitle
67
+ * @property {(handles: string[]) => Promise<{touched: number}>} setFont
68
+ * Applies the font size the current mode asks for, leaving each window where it is.
69
+ * @property {(handles: string[]) => Promise<{touched: number}>} relayout
70
+ * @property {(handle: string) => Promise<{ok: boolean, error?: string}>} focus
71
+ * @property {(opts: {handle: string, pid?: number}) => Promise<{closed: boolean}>} close
72
+ * @property {(handle: string) => Promise<string|null>} readScreen
73
+ * What is on screen right now, or null when this platform cannot look.
74
+ * @property {(text: string, handle: string) => Promise<{pasted: boolean, error?: string}>} paste
75
+ * Puts `text` in front of the user WITHOUT submitting it.
76
+ * @property {(text: string, handle: string) => Promise<{written: boolean, error?: string}>} type
77
+ * Writes `text` and submits it. The fallback when `paste` is unavailable.
78
+ * @property {() => Promise<number>} defaultFontSize
79
+ */
80
+
81
+ /**
82
+ * @typedef {object} PowerAdapter
83
+ * @property {() => Promise<{onAcPower: boolean, charge: number|null}>} state
84
+ * @property {(level: null|'system'|'display') => void} inhibit
85
+ * Keeps the machine awake. 'system' stops idle sleep, 'display' also keeps the screen on,
86
+ * null lets everything sleep normally. Idempotent: calling it with the level already in
87
+ * force does nothing.
88
+ * @property {() => void} release
89
+ * @property {(blocked: boolean) => Promise<void>} setLidSleepBlocked
90
+ * @property {() => Promise<boolean>} lidSleepBlocked
91
+ * @property {() => void} releaseSync
92
+ * The last gesture on the way out, from `process.on('exit')`, where nothing async can
93
+ * still start. This is what stops a machine being left awake forever.
94
+ */
95
+
96
+ /**
97
+ * @typedef {object} MetricsAdapter
98
+ * @property {() => Promise<Map<number, {ppid: number, rss: number, cpu: number, cmd: string}>>} processes
99
+ * @property {() => Promise<{total: number, used: number, app: number, wired: number, compressed: number, cached: number}>} memory
100
+ * @property {() => Promise<{total: number, used: number}>} swap
101
+ * @property {() => Promise<number>} pressureLevel 1 normal, 2 warning, 4 critical
102
+ */
103
+
104
+ /**
105
+ * @typedef {object} ShellAdapter
106
+ * @property {(absPath: string) => Promise<unknown>} revealInFileManager
107
+ * @property {(url: string) => Promise<unknown>} openBrowser
108
+ * @property {() => string|null} findChrome a Chromium-family browser able to print
109
+ * @property {() => Set<string>} homeSkipList directories under $HOME that are never projects
110
+ * @property {() => string} findClaude the Claude Code binary
111
+ */
112
+
113
+ /**
114
+ * @typedef {object} ServiceAdapter
115
+ * @property {(opts: {node: string, entry: string, port: number, logDir: string, tray?: string}) => Promise<void>} install
116
+ * @property {() => Promise<void>} uninstall
117
+ * @property {() => Promise<void>} restart
118
+ * @property {() => Promise<{running: boolean}>} status
119
+ * @property {() => {path: string, what: string}[]} describe
120
+ * Everything this adapter will write, for the consent screen the installer shows before
121
+ * it touches anything. If it is not in this list, the installer must not create it.
122
+ */
123
+
124
+ /**
125
+ * @typedef {object} PlatformAdapter
126
+ * @property {string} name
127
+ * @property {Capabilities} capabilities
128
+ * @property {CapabilityNotes} notes
129
+ * @property {TerminalAdapter} terminal
130
+ * @property {PowerAdapter} power
131
+ * @property {MetricsAdapter} metrics
132
+ * @property {ShellAdapter} shell
133
+ * @property {ServiceAdapter} service
134
+ */
@@ -0,0 +1,254 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { run, runQuiet, runSyncQuiet, which } from '../shared/run.js'
6
+
7
+ /**
8
+ * The optional system tweaks, each one opt-in.
9
+ *
10
+ * These are the parts of installing k0 that reach outside its own files and change something
11
+ * about the machine. They are listed separately from the service because the installer shows
12
+ * them one by one and asks before touching anything: a tool that quietly writes to
13
+ * /etc/sudoers.d and rewrites your Terminal preferences has no business being installed with
14
+ * a single unexplained "y".
15
+ *
16
+ * Every extra can say whether it applies here, whether it is already in place, how to put it
17
+ * there, and how to take it away again.
18
+ */
19
+
20
+ const SUDO = () => which('sudo', ['/usr/bin/sudo'])
21
+ const PMSET = () => which('pmset', ['/usr/bin/pmset'])
22
+ const VISUDO = () => which('visudo', ['/usr/sbin/visudo'])
23
+ const PLUTIL = () => which('plutil', ['/usr/bin/plutil'])
24
+ const DEFAULTS = () => which('defaults', ['/usr/bin/defaults'])
25
+ const KILLALL = () => which('killall', ['/usr/bin/killall'])
26
+ const SW_VERS = () => which('sw_vers', ['/usr/bin/sw_vers'])
27
+ const PS = () => which('ps', ['/bin/ps'])
28
+
29
+ // ── Staying awake with the lid closed ────────────────────────────────────────
30
+ // `caffeinate`, the thing you run by hand in a terminal, only sets *idle* assertions: they
31
+ // stop sleep from inactivity, not the sleep that fires when the lid closes, which is a forced
32
+ // sleep and goes straight past them. The only thing that stops that one too is the system
33
+ // flag `SleepDisabled`, and turning it on takes root.
34
+ //
35
+ // The server runs under launchd with no terminal in front of it, so nobody could type a
36
+ // password. Which is why permission is granted here, once, for that command and nothing
37
+ // else — no wildcards, the arguments spelled out, so the blast radius is exactly two command
38
+ // lines.
39
+
40
+ const SUDOERS = '/etc/sudoers.d/k0-pmset'
41
+
42
+ const sudoersRule = () =>
43
+ `${os.userInfo().username} ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0\n`
44
+
45
+ /** Runs a command with the terminal attached, so sudo can ask for a password. */
46
+ function interactive(file, args) {
47
+ return new Promise((resolve) => {
48
+ const child = spawn(file, args, { stdio: 'inherit' })
49
+ child.on('exit', (code) => resolve(code === 0))
50
+ child.on('error', () => resolve(false))
51
+ })
52
+ }
53
+
54
+ const lidSleep = {
55
+ id: 'lid-sleep',
56
+ title: 'Keep this Mac awake with the lid closed',
57
+ detail:
58
+ 'Grants one administrator rule so k0 can toggle the system sleep flag. That is all it ' +
59
+ 'can ever do as root: that command, with those arguments. Asks for your password once.',
60
+ target: SUDOERS,
61
+ supported: () => true,
62
+
63
+ /**
64
+ * Already fine means the rule exists AND really does grant that command without a password.
65
+ * The file itself cannot be read back (it is 0440 root, as it should be), but `sudo -l`
66
+ * answers the question that actually matters — "will you let me do this without asking?" —
67
+ * without running anything.
68
+ */
69
+ async installed() {
70
+ if (!fs.existsSync(SUDOERS)) return false
71
+ const out = await runQuiet(SUDO(), ['-n', '-l', PMSET(), '-a', 'disablesleep', '1'])
72
+ return !!out
73
+ },
74
+
75
+ async install() {
76
+ const tmp = path.join(os.tmpdir(), `k0-pmset-${process.pid}`)
77
+ fs.writeFileSync(tmp, sudoersRule(), { mode: 0o600 })
78
+ try {
79
+ // A malformed file in /etc/sudoers.d makes `sudo` unusable on the whole machine: it is
80
+ // validated BEFORE being put in place, and if it does not pass, nothing is installed.
81
+ await run(VISUDO(), ['-c', '-f', tmp])
82
+ } catch {
83
+ fs.unlinkSync(tmp)
84
+ return { ok: false, message: 'the rule did not pass visudo, so it was not installed' }
85
+ }
86
+ const ok = await interactive(SUDO(), ['install', '-m', '0440', '-o', 'root', '-g', 'wheel', tmp, SUDOERS])
87
+ fs.unlinkSync(tmp)
88
+ return ok
89
+ ? { ok: true }
90
+ : { ok: false, message: 'permission not granted — k0 will keep the Mac awake only with the lid open' }
91
+ },
92
+
93
+ async uninstall() {
94
+ // Lift the ban on sleeping first, then remove the permission to lift it: the other order
95
+ // would leave the Mac awake forever with no way left to fix itself.
96
+ await runQuiet(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', '0'])
97
+ if (!fs.existsSync(SUDOERS)) return { ok: true }
98
+ const ok = await interactive(SUDO(), ['rm', '-f', SUDOERS])
99
+ return { ok }
100
+ },
101
+ }
102
+
103
+ // ── Shift+Enter inserts a newline ────────────────────────────────────────────
104
+ // Terminal.app, up to macOS 26, does not tell Shift+Enter from Enter: the program receives
105
+ // the same single byte, so Claude Code sends the message instead of starting a new line. We
106
+ // teach the Terminal profile to send Esc+Enter when you press Shift+Enter: that is the
107
+ // sequence Claude Code reads as "new line" (the same one its own /terminal-setup installs for
108
+ // VS Code, Zed and Alacritty). From macOS 27 the Terminal does it by itself and nothing here
109
+ // is touched.
110
+ //
111
+ // The when matters as much as the what: Terminal reads key bindings ONLY at startup, and on
112
+ // quitting it rewrites its preferences from what it had in memory — erasing our entry. Writing
113
+ // it while Terminal is running is therefore useless at best and counterproductive at worst:
114
+ // the only moment the write survives is right after Terminal has died. The menu bar icon
115
+ // catches that moment and calls us back (see menubar/K0MenuBar.swift).
116
+
117
+ const PREFS = path.join(os.homedir(), 'Library', 'Preferences', 'com.apple.Terminal.plist')
118
+ const BACKUP = `${PREFS}.k0.bak`
119
+
120
+ /** How Terminal writes a key in a profile: modifiers first ($ = Shift), then the key code. */
121
+ const KEY = '$000D'
122
+ /** Esc followed by carriage return: the sequence Claude Code reads as "new line". */
123
+ const ESC_ENTER = '\x1b\r'
124
+
125
+ // plutil uses the dot as a path separator: dots inside a profile name have to be protected,
126
+ // or a profile called "My.Profile" would split the path in two.
127
+ const entryPath = (profile) => `Window Settings.${profile.replace(/\./g, '\\.')}.keyMapBoundKeys.${KEY}`
128
+ const dictPath = (profile) => `Window Settings.${profile.replace(/\./g, '\\.')}.keyMapBoundKeys`
129
+
130
+ /**
131
+ * The profiles to touch: the default one and, if different, the one Terminal opens new
132
+ * windows with. These are the same two reads Claude Code's /terminal-setup makes.
133
+ */
134
+ async function profiles() {
135
+ const fallback = (await runQuiet(DEFAULTS(), ['read', 'com.apple.Terminal', 'Default Window Settings'])).trim()
136
+ const startup = (await runQuiet(DEFAULTS(), ['read', 'com.apple.Terminal', 'Startup Window Settings'])).trim()
137
+ return [...new Set([fallback, startup].filter(Boolean))]
138
+ }
139
+
140
+ const hasBinding = (profile) => !!runSyncQuiet(PLUTIL(), ['-extract', entryPath(profile), 'raw', '-o', '-', PREFS])
141
+
142
+ /**
143
+ * pgrep is no good here: it cannot see the Terminal process's command line, and
144
+ * `pgrep -x Terminal` misses it because the name is the full path.
145
+ */
146
+ async function terminalRunning() {
147
+ const out = await runQuiet(PS(), ['-Ao', 'comm='])
148
+ return /Terminal\.app\/Contents\/MacOS\/Terminal/.test(out)
149
+ }
150
+
151
+ async function majorVersion() {
152
+ const out = await runQuiet(SW_VERS(), ['-productVersion'])
153
+ return Number(String(out).trim().split('.')[0]) || 0
154
+ }
155
+
156
+ const shiftEnter = {
157
+ id: 'shift-enter',
158
+ title: 'Shift+Enter starts a new line in Terminal',
159
+ detail:
160
+ 'Adds one key binding to your Terminal profiles, after backing the preferences up. ' +
161
+ 'Without it, Shift+Enter sends the message instead of starting a new line.',
162
+ target: PREFS,
163
+ async supported() {
164
+ return (await majorVersion()) < 27
165
+ },
166
+
167
+ async installed() {
168
+ if (!fs.existsSync(PREFS)) return false
169
+ const list = await profiles()
170
+ return list.length > 0 && list.every(hasBinding)
171
+ },
172
+
173
+ async install() {
174
+ if ((await majorVersion()) >= 27) return { ok: true, message: 'macOS does this by itself now' }
175
+ // Nothing is written while Terminal is running: it would not read the entry anyway, and on
176
+ // quitting it would write its in-memory preferences over the top. The menu bar icon calls
177
+ // us back the moment Terminal closes.
178
+ if (await terminalRunning()) {
179
+ return { ok: false, message: 'Terminal is open — k0 will add it as soon as you quit Terminal' }
180
+ }
181
+ if (!fs.existsSync(PREFS)) return { ok: false, message: 'Terminal preferences not found' }
182
+
183
+ // Before writing, wake up cfprefsd, which caches preferences: if it is still holding
184
+ // anything from a Terminal that just quit, it flushes it to disk NOW. Without this it
185
+ // would flush after us, over the entry we just wrote — which is exactly the trap this
186
+ // whole story started from. Terminal is already dead here, so nothing is taken from anybody.
187
+ await runQuiet(KILLALL(), ['cfprefsd'])
188
+ await new Promise((r) => setTimeout(r, 1000))
189
+
190
+ const list = await profiles()
191
+ let wrote = false
192
+ const failed = []
193
+ for (const profile of list) {
194
+ if (hasBinding(profile)) continue
195
+ // A backup before writing, and only the first time: making it now would save the
196
+ // already-modified preferences, which is to say nothing.
197
+ if (!fs.existsSync(BACKUP)) await runQuiet(DEFAULTS(), ['export', 'com.apple.Terminal', BACKUP])
198
+ // The bindings dictionary has to be created if it is not there yet; if it is, this
199
+ // -insert fails, and that is correct.
200
+ await runQuiet(PLUTIL(), ['-insert', dictPath(profile), '-dictionary', PREFS])
201
+ await runQuiet(PLUTIL(), ['-insert', entryPath(profile), '-string', ESC_ENTER, PREFS])
202
+ if (hasBinding(profile)) wrote = true
203
+ else failed.push(profile)
204
+ }
205
+ // cfprefsd caches preferences: without this, nobody sees what we wrote to the file.
206
+ if (wrote) await runQuiet(KILLALL(), ['cfprefsd'])
207
+ if (failed.length) {
208
+ return {
209
+ ok: false,
210
+ message:
211
+ `could not write it to ${failed.join(', ')}. By hand: Terminal → Settings → Profiles → ` +
212
+ 'Keyboard → "+", key Return, modifier Shift, action "Send Text" with Esc and Enter.',
213
+ }
214
+ }
215
+ return { ok: true }
216
+ },
217
+
218
+ async uninstall() {
219
+ if (!fs.existsSync(PREFS)) return { ok: true }
220
+ let removed = false
221
+ for (const profile of await profiles()) {
222
+ if (!hasBinding(profile)) continue
223
+ // ONLY this entry is deleted: the dictionary and any bindings added by hand stay.
224
+ await runQuiet(PLUTIL(), ['-remove', entryPath(profile), PREFS])
225
+ removed = true
226
+ }
227
+ if (removed) await runQuiet(KILLALL(), ['cfprefsd'])
228
+ return { ok: true, message: removed ? 'reopen Terminal for it to take effect' : undefined }
229
+ },
230
+ }
231
+
232
+ // ── Accessibility ────────────────────────────────────────────────────────────
233
+ // Not something an installer can grant: macOS only lets the user do it, by hand, in System
234
+ // Settings. It is listed anyway so the consent screen can say what is being asked for and,
235
+ // more to the point, what it means.
236
+
237
+ const accessibility = {
238
+ id: 'accessibility',
239
+ title: 'Accessibility permission, so k0 can leave a prompt unsent',
240
+ detail:
241
+ 'k0 simulates a Cmd+V to place your prompt in the terminal without sending it. macOS ' +
242
+ 'only allows that for apps you approve in System Settings → Privacy & Security → ' +
243
+ 'Accessibility. Note that a process with this permission can send keystrokes to any ' +
244
+ 'app. Skipping it breaks nothing: the prompt gets typed and the session starts by itself.',
245
+ target: 'System Settings → Privacy & Security → Accessibility',
246
+ supported: () => true,
247
+ manual: true,
248
+ installed: async () => false,
249
+ install: async () => ({ ok: false, message: 'grant this by hand in System Settings' }),
250
+ uninstall: async () => ({ ok: true, message: 'revoke this by hand in System Settings' }),
251
+ }
252
+
253
+ export const extras = [lidSleep, shiftEnter, accessibility]
254
+ export { shiftEnter }
@@ -0,0 +1,24 @@
1
+ import * as terminal from './terminal.js'
2
+ import * as power from './power.js'
3
+ import * as metrics from './metrics.js'
4
+ import * as shell from './shell.js'
5
+ import * as service from './service.js'
6
+ import { extras } from './extras.js'
7
+ import { NO_CAPABILITIES } from '../contract.js'
8
+
9
+ export const name = 'macOS'
10
+
11
+ export const capabilities = {
12
+ ...NO_CAPABILITIES,
13
+ terminal: terminal.capabilities,
14
+ power: power.capabilities,
15
+ metrics: metrics.capabilities,
16
+ service: service.capabilities,
17
+ tray: true,
18
+ revealInFileManager: true,
19
+ }
20
+
21
+ /** Nothing is missing here, so there is nothing to explain. */
22
+ export const notes = {}
23
+
24
+ export { terminal, power, metrics, shell, service, extras }
@@ -0,0 +1,59 @@
1
+ import os from 'node:os'
2
+ import { runQuiet, which } from '../shared/run.js'
3
+ import { parsePs } from '../shared/ps.js'
4
+
5
+ const OPTS = { maxBuffer: 1 << 24 }
6
+
7
+ const PS = () => which('ps', ['/bin/ps'])
8
+ const VM_STAT = () => which('vm_stat', ['/usr/bin/vm_stat'])
9
+ const SYSCTL = () => which('sysctl', ['/usr/sbin/sysctl'])
10
+
11
+ export async function processes() {
12
+ return parsePs(await runQuiet(PS(), ['-axo', 'pid=,ppid=,rss=,time=,command='], OPTS))
13
+ }
14
+
15
+ /**
16
+ * `vm_stat`, in pages. The entries that matter are the ones Activity Monitor uses to build
17
+ * its "memory used": anonymous pages minus purgeable ones are the applications' memory, and
18
+ * to those you add wired and compressed. The rest — files held in cache — is not memory
19
+ * anybody is short of.
20
+ */
21
+ export function parseVmStat(out) {
22
+ const text = String(out ?? '')
23
+ const page = Number(/page size of (\d+)/.exec(text)?.[1]) || 4096
24
+ const of = (label) => Number(new RegExp(`^${label}:\\s+(\\d+)`, 'm').exec(text)?.[1] || 0) * page
25
+
26
+ const wired = of('Pages wired down')
27
+ const compressed = of('Pages occupied by compressor')
28
+ const app = Math.max(0, of('Anonymous pages') - of('Pages purgeable'))
29
+ return { used: app + wired + compressed, app, wired, compressed, cached: of('File-backed pages') }
30
+ }
31
+
32
+ export async function memory() {
33
+ return { total: os.totalmem(), ...parseVmStat(await runQuiet(VM_STAT(), [], OPTS)) }
34
+ }
35
+
36
+ /** `vm.swapusage: total = 13312.00M used = 12660.19M free = 651.81M` */
37
+ export function parseSwap(out) {
38
+ const grab = (k) => {
39
+ const m = new RegExp(`${k} = ([\\d.]+)([KMG])`).exec(String(out ?? ''))
40
+ if (!m) return 0
41
+ return Number(m[1]) * { K: 1024, M: 1024 ** 2, G: 1024 ** 3 }[m[2]]
42
+ }
43
+ return { total: grab('total'), used: grab('used') }
44
+ }
45
+
46
+ export async function swap() {
47
+ return parseSwap(await runQuiet(SYSCTL(), ['vm.swapusage'], OPTS))
48
+ }
49
+
50
+ /**
51
+ * Not our verdict to invent: `kern.memorystatus_vm_pressure_level` is the kernel's own
52
+ * judgement — 1 normal, 2 warning, 4 critical.
53
+ */
54
+ export async function pressureLevel() {
55
+ const out = await runQuiet(SYSCTL(), ['-n', 'kern.memorystatus_vm_pressure_level'], OPTS)
56
+ return Number(String(out).trim()) || 1
57
+ }
58
+
59
+ export const capabilities = { pressure: true, swap: true }
@@ -0,0 +1,133 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { run, runSyncQuiet, which } from '../shared/run.js'
3
+
4
+ /**
5
+ * Keeping a Mac awake.
6
+ *
7
+ * There are two levers here, and `caffeinate` alone is not enough: its assertions are all
8
+ * *idle* assertions. They stop sleep from inactivity but not the sleep that fires when the
9
+ * lid closes, which is forced and goes straight past them. Only the system flag
10
+ * `SleepDisabled` stops that one, and it wants root — see the installer, which grants that
11
+ * one command and nothing else.
12
+ */
13
+
14
+ const SUDO = () => which('sudo', ['/usr/bin/sudo'])
15
+ const PMSET = () => which('pmset', ['/usr/bin/pmset'])
16
+ const CAFFEINATE = () => which('caffeinate', ['/usr/bin/caffeinate'])
17
+
18
+ /** `-i` is idle sleep, `-m` the disk, `-d` the display. The `-d` is the whole difference. */
19
+ const FLAGS = { system: '-im', display: '-dim' }
20
+
21
+ let child = null // the `caffeinate` child, when there is one
22
+ let activeLevel = null // which level it was started with: changing mode means redoing it
23
+
24
+ /**
25
+ * `SleepDisabled` inside the output of `pmset -g`. When it is zero the line can be missing
26
+ * altogether, so "I did not find it" and "it is 0" are the same answer.
27
+ */
28
+ export function parseSleepDisabled(out) {
29
+ const m = /^\s*SleepDisabled\s+(\d+)/m.exec(String(out ?? ''))
30
+ return !!m && m[1] !== '0'
31
+ }
32
+
33
+ /**
34
+ * Where the power comes from and how much charge is left, from `pmset -g batt`. A desktop
35
+ * has no battery: charge comes back `null`, and no threshold should fire for it.
36
+ */
37
+ export function parseBattery(out) {
38
+ const text = String(out ?? '')
39
+ const charge = /(\d+)%/.exec(text)
40
+ return {
41
+ onAcPower: !/battery power/i.test(text),
42
+ charge: charge ? Number(charge[1]) : null,
43
+ }
44
+ }
45
+
46
+ export async function state() {
47
+ try {
48
+ const stdout = await run(PMSET(), ['-g', 'batt'])
49
+ return parseBattery(stdout)
50
+ } catch {
51
+ // If we cannot ask, assume the case that does no harm: plugged in, no threshold to trip.
52
+ return { onAcPower: true, charge: null }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The `caffeinate` for this level. Changing mode changes the flags, and a running process
58
+ * cannot have its flags changed: it gets killed and remade. The `-w <our pid>` is the first
59
+ * safety net — it stops by itself when the server dies, even if the server is killed
60
+ * outright and never gets to switch anything off.
61
+ */
62
+ export function inhibit(level) {
63
+ if (child && activeLevel === level) return
64
+ release()
65
+ if (!level) return
66
+ const flag = FLAGS[level]
67
+ if (!flag) return
68
+ const spawned = spawn(CAFFEINATE(), [flag, '-w', String(process.pid)], { stdio: 'ignore' })
69
+ child = spawned
70
+ activeLevel = level
71
+ // Comparing against `spawned` is not pedantry: the `exit` of the KILLED process arrives
72
+ // AFTER the new one has already started, and a handler that blindly cleared `child` would
73
+ // wipe out the reference to the new one. On the next pass k0 would believe it had none and
74
+ // start a second — measured: two live `caffeinate` processes, one of them with nobody left
75
+ // who knows about it.
76
+ const forget = () => {
77
+ if (child !== spawned) return
78
+ child = null
79
+ activeLevel = null
80
+ }
81
+ spawned.on('error', forget)
82
+ spawned.on('exit', forget)
83
+ spawned.unref() // it is not its job to keep the server alive
84
+ }
85
+
86
+ export function release() {
87
+ activeLevel = null
88
+ if (!child) return
89
+ try {
90
+ child.kill()
91
+ } catch {
92
+ /* already gone on its own */
93
+ }
94
+ child = null
95
+ }
96
+
97
+ /**
98
+ * `sudo -n`: the `-n` is the part that matters. Without the rule in /etc/sudoers.d the
99
+ * command fails IMMEDIATELY instead of hanging waiting for a password nobody here can type —
100
+ * the server runs under launchd, with no terminal in front of it.
101
+ */
102
+ export async function setLidSleepBlocked(blocked) {
103
+ try {
104
+ await run(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', blocked ? '1' : '0'])
105
+ } catch {
106
+ /* no sudo rule: carry on with caffeinate alone */
107
+ }
108
+ }
109
+
110
+ export async function lidSleepBlocked() {
111
+ try {
112
+ return parseSleepDisabled(await run(PMSET(), ['-g']))
113
+ } catch {
114
+ return false
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The last gesture on the way out. Synchronous on purpose: from `process.on('exit')` nothing
120
+ * asynchronous gets to start any more, and this is the net that stops a Mac being left awake
121
+ * forever because k0 went away while it was on.
122
+ */
123
+ export function releaseSync() {
124
+ release()
125
+ runSyncQuiet(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', '0'], { stdio: 'ignore', timeout: 4000 })
126
+ }
127
+
128
+ export const capabilities = {
129
+ keepAwake: true,
130
+ keepDisplayAwake: true,
131
+ lidSleep: true,
132
+ battery: true,
133
+ }