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,88 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import { runQuiet, which } from '../shared/run.js'
4
+ import { parsePs } from '../shared/ps.js'
5
+
6
+ const PS = () => which('ps', ['/bin/ps', '/usr/bin/ps'])
7
+
8
+ export async function processes() {
9
+ // Same five fields in the same order as macOS, so one parser covers both. `args` is
10
+ // Linux's name for what BSD calls `command`.
11
+ return parsePs(await runQuiet(PS(), ['-eo', 'pid=,ppid=,rss=,time=,args='], { maxBuffer: 1 << 24 }))
12
+ }
13
+
14
+ const read = (file) => {
15
+ try {
16
+ return fs.readFileSync(file, 'utf8')
17
+ } catch {
18
+ return ''
19
+ }
20
+ }
21
+
22
+ /**
23
+ * /proc/meminfo, in kilobytes.
24
+ *
25
+ * "Used" is total minus MemAvailable, which is the kernel's own estimate of what a new
26
+ * process could get hold of without swapping — a far better answer than total minus free,
27
+ * which counts every cached file as memory somebody is short of.
28
+ *
29
+ * The macOS shape has fields Linux has no equivalent for. `wired` maps to the kernel's
30
+ * unreclaimable slab, `compressed` to zswap where it is on, and both are simply zero when
31
+ * the machine has nothing to put there.
32
+ */
33
+ export function parseMeminfo(text) {
34
+ const kb = (label) => Number(new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(String(text ?? ''))?.[1] || 0) * 1024
35
+ const total = kb('MemTotal') || os.totalmem()
36
+ const available = kb('MemAvailable')
37
+ const cached = kb('Cached') + kb('Buffers')
38
+ const wired = kb('SUnreclaim')
39
+ const compressed = kb('Zswapped')
40
+ const used = available ? Math.max(0, total - available) : Math.max(0, total - kb('MemFree') - cached)
41
+ return { total, used, app: Math.max(0, used - wired - compressed), wired, compressed, cached }
42
+ }
43
+
44
+ export async function memory() {
45
+ return parseMeminfo(read('/proc/meminfo'))
46
+ }
47
+
48
+ export function parseSwapFromMeminfo(text) {
49
+ const kb = (label) => Number(new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(String(text ?? ''))?.[1] || 0) * 1024
50
+ const total = kb('SwapTotal')
51
+ return { total, used: Math.max(0, total - kb('SwapFree')) }
52
+ }
53
+
54
+ export async function swap() {
55
+ return parseSwapFromMeminfo(read('/proc/meminfo'))
56
+ }
57
+
58
+ /**
59
+ * Pressure Stall Information: the fraction of the last ten seconds in which at least one task
60
+ * was stalled waiting for memory. It is the closest thing Linux has to the macOS kernel's
61
+ * pressure verdict, and like it, it is a measurement rather than a guess — so the thresholds
62
+ * are the only part k0 chooses.
63
+ *
64
+ * Not every kernel is built with PSI, and cgroup-less containers do not expose it either;
65
+ * where the file is missing, `capabilities.pressure` says so and the board falls back to
66
+ * judging by how full memory is.
67
+ */
68
+ export function parsePressure(text) {
69
+ const avg10 = Number(/some\s+avg10=([\d.]+)/.exec(String(text ?? ''))?.[1] || 0)
70
+ if (avg10 >= 20) return 4
71
+ if (avg10 >= 5) return 2
72
+ return 1
73
+ }
74
+
75
+ export async function pressureLevel() {
76
+ return parsePressure(read('/proc/pressure/memory'))
77
+ }
78
+
79
+ export const capabilities = () => ({
80
+ pressure: fs.existsSync('/proc/pressure/memory'),
81
+ swap: true,
82
+ })
83
+
84
+ export const notes = {
85
+ 'metrics.pressure':
86
+ 'This kernel does not expose /proc/pressure/memory, so k0 judges memory pressure by how ' +
87
+ 'full memory is rather than by how much it is actually stalling.',
88
+ }
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { which } from '../shared/run.js'
5
+
6
+ /**
7
+ * Keeping a Linux machine awake.
8
+ *
9
+ * The equivalent of `caffeinate` is `systemd-inhibit`, which holds a lock for as long as the
10
+ * command it runs is alive — so k0 runs a `sleep infinity` under it and kills that when the
11
+ * mode changes. Same shape as macOS, different lever.
12
+ *
13
+ * What is NOT here is the lid. On macOS a single system flag covers it; on Linux the lid is
14
+ * handled by logind, and changing it means editing /etc/systemd/logind.conf as root and
15
+ * reloading the daemon — a machine-wide setting that outlives k0 and that k0 has no business
16
+ * rewriting behind your back. So `lidSleep` is reported as unavailable, with a note that says
17
+ * what to change if you want it. Better a switch that is visibly off than one that lies.
18
+ */
19
+
20
+ const SYSTEMD_INHIBIT = () => which('systemd-inhibit')
21
+ const GNOME_INHIBIT = () => which('gnome-session-inhibit')
22
+
23
+ const SUPPLY = '/sys/class/power_supply'
24
+
25
+ let children = []
26
+ let activeLevel = null
27
+
28
+ /**
29
+ * Power source and charge, straight from sysfs. `AC`/`ADP*` entries report whether the mains
30
+ * are connected; `BAT*` entries report capacity. No battery at all means a desktop, and a
31
+ * desktop should never trip a battery threshold — hence `charge: null` rather than 100.
32
+ */
33
+ export function readPowerSupply(dir = SUPPLY) {
34
+ let onAcPower = null
35
+ let charge = null
36
+ let entries = []
37
+ try {
38
+ entries = fs.readdirSync(dir)
39
+ } catch {
40
+ return { onAcPower: true, charge: null }
41
+ }
42
+ for (const name of entries) {
43
+ const read = (file) => {
44
+ try {
45
+ return fs.readFileSync(path.join(dir, name, file), 'utf8').trim()
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+ const type = read('type')
51
+ if (type === 'Mains') {
52
+ if (read('online') === '1') onAcPower = true
53
+ else if (onAcPower === null) onAcPower = false
54
+ } else if (type === 'Battery') {
55
+ const capacity = Number(read('capacity'))
56
+ if (Number.isFinite(capacity) && read('capacity') !== '') charge = capacity
57
+ }
58
+ }
59
+ return { onAcPower: onAcPower === null ? true : onAcPower, charge }
60
+ }
61
+
62
+ export async function state() {
63
+ return readPowerSupply()
64
+ }
65
+
66
+ function startInhibitor(bin, args) {
67
+ const child = spawn(bin, args, { stdio: 'ignore' })
68
+ child.on('error', () => {})
69
+ child.unref()
70
+ children.push(child)
71
+ }
72
+
73
+ export function inhibit(level) {
74
+ if (activeLevel === level) return
75
+ release()
76
+ if (!level) return
77
+ activeLevel = level
78
+
79
+ const systemd = SYSTEMD_INHIBIT()
80
+ if (systemd) {
81
+ startInhibitor(systemd, [
82
+ '--what=idle:sleep',
83
+ '--who=k0',
84
+ '--why=A Claude Code session is running',
85
+ '--mode=block',
86
+ 'sleep',
87
+ 'infinity',
88
+ ])
89
+ }
90
+ // Stopping the *screen* from going dark is a desktop-environment matter, not a logind one.
91
+ // gnome-session-inhibit covers GNOME, which is the common case; elsewhere the display may
92
+ // still blank, and `capabilities` says so.
93
+ if (level === 'display') {
94
+ const gnome = GNOME_INHIBIT()
95
+ if (gnome) startInhibitor(gnome, ['--inhibit', 'idle', '--reason', 'k0 driving mode', 'sleep', 'infinity'])
96
+ }
97
+ }
98
+
99
+ export function release() {
100
+ activeLevel = null
101
+ for (const child of children) {
102
+ try {
103
+ child.kill()
104
+ } catch {
105
+ /* already gone */
106
+ }
107
+ }
108
+ children = []
109
+ }
110
+
111
+ /** Not ours to change: see the note at the top of this file. */
112
+ export async function setLidSleepBlocked() {}
113
+
114
+ export async function lidSleepBlocked() {
115
+ return false
116
+ }
117
+
118
+ export function releaseSync() {
119
+ release()
120
+ }
121
+
122
+ export const capabilities = () => ({
123
+ keepAwake: !!SYSTEMD_INHIBIT(),
124
+ keepDisplayAwake: !!GNOME_INHIBIT(),
125
+ lidSleep: false,
126
+ battery: fs.existsSync(SUPPLY),
127
+ })
128
+
129
+ export const notes = {
130
+ 'power.lidSleep':
131
+ 'On Linux the lid is handled by logind. To keep working with the lid closed, set ' +
132
+ 'HandleLidSwitch=ignore in /etc/systemd/logind.conf and reload logind. k0 will not ' +
133
+ 'change a machine-wide setting for you.',
134
+ 'power.keepDisplayAwake':
135
+ 'Keeping the screen on needs a desktop-level inhibitor. k0 uses gnome-session-inhibit ' +
136
+ 'where it exists; on other desktops the display may still blank.',
137
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { run, runQuiet, which } from '../shared/run.js'
5
+
6
+ /**
7
+ * Background services, systemd user-unit edition.
8
+ *
9
+ * User units, not system ones: k0 runs as you, needs your session's environment to find a
10
+ * display and a terminal, and has no business existing when you are not logged in. The one
11
+ * wrinkle is that a user unit normally stops when your last session ends — `loginctl
12
+ * enable-linger` is what keeps it alive on a headless box, and the installer only mentions
13
+ * it rather than turning it on, because it is a policy decision about your account.
14
+ */
15
+
16
+ const SYSTEMCTL = () => which('systemctl')
17
+ const UNIT_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user')
18
+
19
+ export const SERVER_UNIT = 'k0.service'
20
+ export const TRAY_UNIT = 'k0-tray.service'
21
+
22
+ const unitPath = (unit) => path.join(UNIT_DIR, unit)
23
+
24
+ function unitFile({ description, exec, port, logDir, workingDirectory }) {
25
+ return `[Unit]
26
+ Description=${description}
27
+ After=graphical-session.target
28
+
29
+ [Service]
30
+ Type=simple
31
+ ExecStart=${exec}
32
+ Environment=K0_PORT=${port}
33
+ ${workingDirectory ? `WorkingDirectory=${workingDirectory}\n` : ''}Restart=always
34
+ RestartSec=2
35
+ StandardOutput=append:${path.join(logDir, description.replace(/\s+/g, '-').toLowerCase())}.out.log
36
+ StandardError=append:${path.join(logDir, description.replace(/\s+/g, '-').toLowerCase())}.err.log
37
+
38
+ [Install]
39
+ WantedBy=default.target
40
+ `
41
+ }
42
+
43
+ const quote = (s) => (/[\s"']/.test(s) ? JSON.stringify(s) : s)
44
+
45
+ export async function install({ node, entry, port, logDir, tray, appDir }) {
46
+ const systemctl = SYSTEMCTL()
47
+ if (!systemctl) throw new Error('k0 needs systemd to install a background service on Linux.')
48
+ fs.mkdirSync(UNIT_DIR, { recursive: true })
49
+ fs.mkdirSync(logDir, { recursive: true })
50
+
51
+ fs.writeFileSync(
52
+ unitPath(SERVER_UNIT),
53
+ unitFile({
54
+ description: 'k0 server',
55
+ exec: `${quote(node)} ${quote(entry)}`,
56
+ port,
57
+ logDir,
58
+ workingDirectory: appDir,
59
+ })
60
+ )
61
+ if (tray && fs.existsSync(tray)) {
62
+ const python = which('python3')
63
+ fs.writeFileSync(
64
+ unitPath(TRAY_UNIT),
65
+ unitFile({ description: 'k0 tray', exec: `${quote(python || 'python3')} ${quote(tray)}`, port, logDir })
66
+ )
67
+ }
68
+
69
+ await run(systemctl, ['--user', 'daemon-reload'])
70
+ await run(systemctl, ['--user', 'enable', '--now', SERVER_UNIT])
71
+ if (tray && fs.existsSync(unitPath(TRAY_UNIT))) {
72
+ await runQuiet(systemctl, ['--user', 'enable', '--now', TRAY_UNIT])
73
+ }
74
+ }
75
+
76
+ export async function uninstall() {
77
+ const systemctl = SYSTEMCTL()
78
+ for (const unit of [SERVER_UNIT, TRAY_UNIT]) {
79
+ if (systemctl) {
80
+ await runQuiet(systemctl, ['--user', 'disable', '--now', unit])
81
+ }
82
+ try {
83
+ fs.unlinkSync(unitPath(unit))
84
+ } catch {
85
+ /* never installed */
86
+ }
87
+ }
88
+ if (systemctl) await runQuiet(systemctl, ['--user', 'daemon-reload'])
89
+ }
90
+
91
+ export async function restart() {
92
+ await runQuiet(SYSTEMCTL(), ['--user', 'restart', SERVER_UNIT])
93
+ }
94
+
95
+ export async function status() {
96
+ const out = await runQuiet(SYSTEMCTL(), ['--user', 'is-active', SERVER_UNIT])
97
+ return { running: String(out).trim() === 'active' }
98
+ }
99
+
100
+ export const describe = () => [
101
+ { path: unitPath(SERVER_UNIT), what: 'a user service that starts k0 when you log in' },
102
+ { path: unitPath(TRAY_UNIT), what: 'a user service for the tray icon' },
103
+ ]
104
+
105
+ export const capabilities = () => ({ autostart: !!SYSTEMCTL() })
106
+
107
+ export const notes = {
108
+ 'service.autostart':
109
+ 'systemd was not found, so k0 cannot register itself to start at login. Run it by hand ' +
110
+ 'with `k0-board start` or from your desktop’s own autostart configuration.',
111
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { run, runQuiet, runSyncQuiet, which } from '../shared/run.js'
5
+
6
+ const XDG_OPEN = () => which('xdg-open')
7
+ const DBUS_SEND = () => which('dbus-send')
8
+
9
+ /**
10
+ * "Show me this file in the file manager" has a proper freedesktop answer — the FileManager1
11
+ * interface, which selects the file rather than just opening its folder — and a fallback for
12
+ * desktops that do not implement it, which is to open the containing directory and let you
13
+ * find it yourself.
14
+ */
15
+ export async function revealInFileManager(abs) {
16
+ const dbus = DBUS_SEND()
17
+ if (dbus) {
18
+ const uri = `file://${encodeURI(abs)}`
19
+ const out = await runQuiet(dbus, [
20
+ '--session',
21
+ '--dest=org.freedesktop.FileManager1',
22
+ '--type=method_call',
23
+ '/org/freedesktop/FileManager1',
24
+ 'org.freedesktop.FileManager1.ShowItems',
25
+ `array:string:${uri}`,
26
+ 'string:',
27
+ ])
28
+ if (out !== '') return
29
+ }
30
+ const open = XDG_OPEN()
31
+ if (open) await runQuiet(open, [path.dirname(abs)])
32
+ }
33
+
34
+ export const openBrowser = (url) => run(XDG_OPEN(), [url], { timeout: 4000 })
35
+
36
+ const CHROME_CANDIDATES = [
37
+ 'google-chrome',
38
+ 'google-chrome-stable',
39
+ 'chromium',
40
+ 'chromium-browser',
41
+ 'brave-browser',
42
+ 'microsoft-edge',
43
+ ]
44
+
45
+ export function findChrome() {
46
+ for (const name of CHROME_CANDIDATES) {
47
+ const bin = which(name)
48
+ if (bin) return bin
49
+ }
50
+ for (const p of ['/snap/bin/chromium', '/usr/lib/chromium/chromium']) {
51
+ if (fs.existsSync(p)) return p
52
+ }
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Directories under $HOME that are never projects.
58
+ *
59
+ * The XDG names are the ones the desktop creates, and unlike macOS they can be localised —
60
+ * a French install has `Bureau` and `Téléchargements`. So the real list is read from
61
+ * user-dirs.dirs when it is there, with the English names as a floor for machines that have
62
+ * no desktop at all.
63
+ */
64
+ export function homeSkipList() {
65
+ const skip = new Set(['Desktop', 'Documents', 'Downloads', 'Music', 'Pictures', 'Public', 'Templates', 'Videos', 'snap'])
66
+ const config = path.join(
67
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
68
+ 'user-dirs.dirs'
69
+ )
70
+ try {
71
+ for (const line of fs.readFileSync(config, 'utf8').split('\n')) {
72
+ const m = /^XDG_\w+_DIR="\$HOME\/(.+)"/.exec(line.trim())
73
+ if (m) skip.add(m[1].replace(/\/$/, ''))
74
+ }
75
+ } catch {
76
+ /* no desktop configuration: the defaults above are enough */
77
+ }
78
+ return skip
79
+ }
80
+
81
+ let claudeBin = null
82
+
83
+ export function findClaude() {
84
+ if (claudeBin) return claudeBin
85
+ const candidates = [
86
+ path.join(os.homedir(), '.local/bin/claude'),
87
+ '/usr/local/bin/claude',
88
+ '/usr/bin/claude',
89
+ ]
90
+ for (const c of candidates) if (fs.existsSync(c)) return (claudeBin = c)
91
+ const shell = process.env.SHELL || '/bin/bash'
92
+ const fromShell = runSyncQuiet(shell, ['-lc', 'command -v claude']).trim()
93
+ claudeBin = fromShell || which('claude') || 'claude'
94
+ return claudeBin
95
+ }
96
+
97
+ export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN() })
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """k0's tray icon for Linux.
3
+
4
+ The counterpart of menubar/K0MenuBar.swift, and deliberately the same shape: poll
5
+ /api/status every couple of seconds, tint a small square with the colour of the most urgent
6
+ session, and put the sessions that are waiting for you at the top of the menu so one click
7
+ brings the right terminal back.
8
+
9
+ Written in Python because it is the only language with a tray binding that is already
10
+ installed on a normal desktop: GNOME, KDE and the rest ship python3 with PyGObject. Anything
11
+ else would mean asking people to install a toolkit before they can see an icon. There are no
12
+ third-party imports here — the PNG the icon is made of is written by hand with zlib, which is
13
+ in the standard library, precisely so that this file never needs a pip install.
14
+
15
+ If PyGObject or an AppIndicator implementation is missing, this exits quietly. The board and
16
+ the server work perfectly without a tray icon; refusing to start would be worse than not
17
+ having one.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import struct
23
+ import subprocess
24
+ import sys
25
+ import urllib.error
26
+ import urllib.request
27
+ import zlib
28
+
29
+ try:
30
+ import gi
31
+
32
+ gi.require_version("Gtk", "3.0")
33
+ try:
34
+ gi.require_version("AyatanaAppIndicator3", "0.1")
35
+ from gi.repository import AyatanaAppIndicator3 as AppIndicator
36
+ except (ValueError, ImportError):
37
+ gi.require_version("AppIndicator3", "0.1")
38
+ from gi.repository import AppIndicator3 as AppIndicator
39
+ from gi.repository import GLib, Gtk
40
+ except (ImportError, ValueError) as err: # pragma: no cover - depends on the desktop
41
+ print(f"k0: no tray support on this desktop ({err}); the board still works", file=sys.stderr)
42
+ raise SystemExit(0)
43
+
44
+
45
+ PORT = os.environ.get("K0_PORT", "4319")
46
+ BASE = f"http://127.0.0.1:{PORT}"
47
+ POLL_SECONDS = 2
48
+
49
+ CACHE = os.path.join(
50
+ os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "k0", "tray"
51
+ )
52
+
53
+ # The same colours and the same order of urgency as the board and the macOS menu bar icon.
54
+ # ASK first because a question blocks everything behind it; COMPLETED never gets here.
55
+ URGENCY = ["ASK", "PLANNED", "IDLE", "WORKING", "PLANNING"]
56
+ COLOURS = {
57
+ "ASK": (0xE8, 0x6A, 0x33),
58
+ "PLANNED": (0x6C, 0x8E, 0xBF),
59
+ "IDLE": (0xE0, 0xC0, 0x4A),
60
+ "WORKING": (0x5B, 0xA8, 0x6A),
61
+ "PLANNING": (0x8E, 0x7C, 0xC3),
62
+ "quiet": (0x9A, 0x9A, 0x9A),
63
+ "down": (0xB0, 0x50, 0x50),
64
+ }
65
+ LABELS = {
66
+ "ASK": "Needs answer",
67
+ "PLANNED": "Needs approval",
68
+ "IDLE": "Your turn",
69
+ "WORKING": "Working",
70
+ "PLANNING": "Planning",
71
+ }
72
+ MODES = [("sleep", "Sleep"), ("away", "Away"), ("nerd", "Nerd"), ("driving", "Driving")]
73
+
74
+
75
+ def png(rgb, size=22):
76
+ """A solid rounded square, as PNG bytes.
77
+
78
+ Hand-rolled so the tray needs nothing but the standard library. The corners are cut by
79
+ simply not drawing the four corner pixels, which at this size reads as rounded.
80
+ """
81
+ r, g, b = rgb
82
+ rows = bytearray()
83
+ for y in range(size):
84
+ rows.append(0) # PNG filter type for this scanline: none
85
+ for x in range(size):
86
+ edge = (x < 2 or x >= size - 2) and (y < 2 or y >= size - 2)
87
+ rows += bytes((r, g, b, 0 if edge else 255))
88
+
89
+ def chunk(tag, data):
90
+ return (
91
+ struct.pack(">I", len(data))
92
+ + tag
93
+ + data
94
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
95
+ )
96
+
97
+ header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
98
+ return (
99
+ b"\x89PNG\r\n\x1a\n"
100
+ + chunk(b"IHDR", header)
101
+ + chunk(b"IDAT", zlib.compress(bytes(rows), 9))
102
+ + chunk(b"IEND", b"")
103
+ )
104
+
105
+
106
+ def icon_for(state):
107
+ """Write the icon for `state` once and hand back its path."""
108
+ os.makedirs(CACHE, exist_ok=True)
109
+ path = os.path.join(CACHE, f"{state}.png")
110
+ if not os.path.exists(path):
111
+ with open(path, "wb") as fh:
112
+ fh.write(png(COLOURS.get(state, COLOURS["quiet"])))
113
+ return path
114
+
115
+
116
+ def api(path, payload=None):
117
+ """One request to the server. Returns None when the server is not answering."""
118
+ data = json.dumps(payload).encode() if payload is not None else None
119
+ request = urllib.request.Request(
120
+ BASE + path,
121
+ data=data,
122
+ headers={"Content-Type": "application/json", "Origin": BASE},
123
+ method="POST" if data else "GET",
124
+ )
125
+ try:
126
+ with urllib.request.urlopen(request, timeout=3) as response:
127
+ body = response.read()
128
+ return json.loads(body) if body else {}
129
+ except (urllib.error.URLError, OSError, ValueError):
130
+ return None
131
+
132
+
133
+ def notify(title, body):
134
+ try:
135
+ subprocess.Popen(["notify-send", "-a", "k0", title, body])
136
+ except OSError:
137
+ pass # no notification daemon: not worth complaining about
138
+
139
+
140
+ class Tray:
141
+ def __init__(self):
142
+ self.indicator = AppIndicator.Indicator.new(
143
+ "k0", icon_for("quiet"), AppIndicator.IndicatorCategory.APPLICATION_STATUS
144
+ )
145
+ self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
146
+ self.menu = Gtk.Menu()
147
+ self.indicator.set_menu(self.menu)
148
+ self.seen = set()
149
+ self.first_pass = True
150
+ self.mode = None
151
+ self.build_menu([], down=True)
152
+ GLib.timeout_add_seconds(POLL_SECONDS, self.poll)
153
+ self.poll()
154
+
155
+ # ── the loop ────────────────────────────────────────────────────────────
156
+ def poll(self):
157
+ status = api("/api/status")
158
+ if status is None:
159
+ self.indicator.set_icon_full(icon_for("down"), "k0: server not responding")
160
+ self.build_menu([], down=True)
161
+ return True
162
+
163
+ waiting = status.get("waiting") or []
164
+ urgent = status.get("urgent")
165
+ self.mode = status.get("mode")
166
+ self.indicator.set_icon_full(
167
+ icon_for(urgent or "quiet"),
168
+ LABELS.get(urgent, "k0 — nothing waiting for you"),
169
+ )
170
+ self.build_menu(waiting, down=False)
171
+ self.announce(waiting)
172
+ return True
173
+
174
+ def announce(self, waiting):
175
+ """Notify about cards that have just started waiting, not about every one every time."""
176
+ now = {(card["id"], card["status"]) for card in waiting}
177
+ if not self.first_pass:
178
+ for card in waiting:
179
+ if (card["id"], card["status"]) not in self.seen:
180
+ notify(LABELS.get(card["status"], "k0"), card.get("title", ""))
181
+ self.seen = now
182
+ self.first_pass = False
183
+
184
+ # ── the menu ────────────────────────────────────────────────────────────
185
+ def build_menu(self, waiting, down):
186
+ for child in self.menu.get_children():
187
+ self.menu.remove(child)
188
+
189
+ if down:
190
+ self.add_item("Server not responding", None, enabled=False)
191
+ elif not waiting:
192
+ self.add_item("Nothing waiting for you", None, enabled=False)
193
+ else:
194
+ for card in waiting:
195
+ label = f"{LABELS.get(card['status'], card['status'])} — {card.get('title', '')}"
196
+ self.add_item(label, lambda _w, c=card: api(f"/api/card/{c['id']}/focus", {}))
197
+
198
+ self.menu.append(Gtk.SeparatorMenuItem())
199
+ for value, label in MODES:
200
+ item = Gtk.CheckMenuItem(label=label)
201
+ item.set_active(self.mode == value)
202
+ item.connect("activate", self.on_mode, value)
203
+ item.show()
204
+ self.menu.append(item)
205
+
206
+ self.menu.append(Gtk.SeparatorMenuItem())
207
+ self.add_item("Dashboard", lambda _w: subprocess.Popen(["xdg-open", BASE]))
208
+ self.add_item("Restart server", lambda _w: subprocess.Popen(["systemctl", "--user", "restart", "k0.service"]))
209
+ self.add_item("Quit", lambda _w: Gtk.main_quit())
210
+ self.menu.show_all()
211
+
212
+ def add_item(self, label, handler, enabled=True):
213
+ item = Gtk.MenuItem(label=label)
214
+ item.set_sensitive(enabled and handler is not None)
215
+ if handler:
216
+ item.connect("activate", handler)
217
+ item.show()
218
+ self.menu.append(item)
219
+
220
+ def on_mode(self, widget, value):
221
+ # A CheckMenuItem fires `activate` when it is rebuilt too, which would post the mode
222
+ # back to the server in an endless loop. Only a real change is worth a request.
223
+ if widget.get_active() and self.mode != value:
224
+ api("/api/mode", {"mode": value})
225
+
226
+
227
+ if __name__ == "__main__":
228
+ Tray()
229
+ Gtk.main()
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Turning "run this program, in this directory, with these arguments" into one line a shell
3
+ * will accept.
4
+ *
5
+ * It has to be one line because that is all a terminal can be handed: there is no argv on the
6
+ * far side, only text typed into a shell. Which makes quoting the whole of the problem — a
7
+ * project directory called `My Projects` or `it's-mine` has to survive the trip intact, and
8
+ * a prompt passed as an argument can contain anything at all.
9
+ */
10
+
11
+ /** POSIX single quotes: everything is literal inside them, and a quote ends and reopens them. */
12
+ export const posixQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
13
+
14
+ export const posixCommand = ({ cwd, bin, args = [] }) =>
15
+ ['cd', posixQuote(cwd), '&&', posixQuote(bin), ...args.map(posixQuote)].join(' ')
16
+
17
+ /** PowerShell single quotes: same idea, but a quote is escaped by doubling it. */
18
+ export const powershellQuote = (s) => `'${String(s).replace(/'/g, "''")}'`
19
+
20
+ export const powershellCommand = ({ cwd, bin, args = [] }) =>
21
+ [
22
+ `Set-Location ${powershellQuote(cwd)};`,
23
+ '&',
24
+ powershellQuote(bin),
25
+ ...args.map(powershellQuote),
26
+ ].join(' ')