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,561 @@
1
+ // k0 — the icon in the menu bar.
2
+ //
3
+ // It knows nothing about sessions, transcripts or terminals: it asks the server what is waiting
4
+ // for you, tints the icon with the colour of the most urgent thing, and draws a short menu. All
5
+ // the intelligence stays in the server; this is only the face.
6
+
7
+ import AppKit
8
+ import UserNotifications
9
+
10
+ let port = ProcessInfo.processInfo.environment["K0_PORT"] ?? "4319"
11
+ // API calls go to the number, which depends on no name at all. The board in the browser opens
12
+ // with k0.localhost instead, so the browser learns that "k0" means this.
13
+ let base = "http://127.0.0.1:\(port)"
14
+ let boardURL = "http://k0.localhost:\(port)"
15
+
16
+ /// Where k0 keeps the things that outlive a reinstall. Mirrors `server/paths.js`.
17
+ let k0Home: URL = {
18
+ if let custom = ProcessInfo.processInfo.environment["K0_HOME"] {
19
+ return URL(fileURLWithPath: custom)
20
+ }
21
+ return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".k0")
22
+ }()
23
+
24
+ // MARK: - What the server answers
25
+
26
+ struct Waiting: Decodable {
27
+ let id: Int
28
+ let title: String
29
+ let project: String
30
+ let status: String
31
+ let window: String?
32
+ }
33
+
34
+ struct Status: Decodable {
35
+ let urgent: String?
36
+ let waiting: [Waiting]
37
+ /// Which of the four modes we are in: `sleep`, `away`, `nerd`, `driving`. The server holds
38
+ /// it, not us: it is the same control that is on the board, and two separate copies of the
39
+ /// same thing would end up disagreeing.
40
+ ///
41
+ /// Optional on purpose: a server older than this icon does not send the field, and with a
42
+ /// plain `String` the whole response would fail to decode — the icon would say "server not
43
+ /// responding" because of one field it could not find.
44
+ let mode: String?
45
+ }
46
+
47
+ /// The four modes, in the order of the scale: sleepiest to most awake. The words are the
48
+ /// server's (`server/mode.js`); the titles are what the user reads.
49
+ enum Modes {
50
+ static let all: [(String, String)] = [
51
+ ("sleep", "Sleep"),
52
+ ("away", "Away"),
53
+ ("nerd", "Nerd"),
54
+ ("driving", "Driving"),
55
+ ]
56
+ }
57
+
58
+ /// The same colours as the board, but saturated: in a menu bar, pastels disappear.
59
+ func colour(_ state: String?) -> NSColor {
60
+ switch state {
61
+ case "ASK": return NSColor(srgbRed: 0.94, green: 0.27, blue: 0.27, alpha: 1) // red
62
+ case "PLANNED": return NSColor(srgbRed: 0.98, green: 0.45, blue: 0.09, alpha: 1) // orange
63
+ case "IDLE": return NSColor(srgbRed: 0.92, green: 0.70, blue: 0.03, alpha: 1) // yellow
64
+ case "WORKING",
65
+ "PLANNING": return NSColor(srgbRed: 0.23, green: 0.51, blue: 0.96, alpha: 1) // blue
66
+ default: return NSColor(srgbRed: 0.61, green: 0.64, blue: 0.69, alpha: 1) // grey
67
+ }
68
+ }
69
+
70
+ /// The same labels as the dashboard.
71
+ let label: [String: String] = [
72
+ "ASK": "Needs answer",
73
+ "PLANNED": "Needs approval",
74
+ "IDLE": "Your turn",
75
+ "WORKING": "Working",
76
+ "PLANNING": "Planning",
77
+ ]
78
+
79
+ /// The notifications switch, which stays as it was left even after a restart. Switched off, the
80
+ /// icon still changes colour: what disappears is only the notification.
81
+ enum Notifications {
82
+ static let key = "k0.notifications"
83
+
84
+ static var enabled: Bool {
85
+ get {
86
+ if UserDefaults.standard.object(forKey: key) == nil { return true } // on by default
87
+ return UserDefaults.standard.bool(forKey: key)
88
+ }
89
+ set { UserDefaults.standard.set(newValue, forKey: key) }
90
+ }
91
+ }
92
+
93
+ // MARK: - Cmd+V for images
94
+
95
+ /// In Terminal, Cmd+V with an image on the clipboard does nothing: Terminal swallows the
96
+ /// keystroke before Claude Code sees it, and an image would not go through a terminal anyway.
97
+ /// Inside an editor it works only because an extension is doing this same job.
98
+ ///
99
+ /// The keystroke is not intercepted here — that road wants the Accessibility permission and
100
+ /// needs macOS to make it stick, which is far from a given. Instead this works on the clipboard,
101
+ /// which asks for no permission at all: **while Terminal is in front**, an image on the
102
+ /// clipboard is saved to a file and its path is put in its place. So your ordinary Cmd+V pastes
103
+ /// the path, and Claude Code recognises it and attaches it as `[Image #1]`. The moment you leave
104
+ /// Terminal the image goes back on the clipboard exactly as you copied it.
105
+ final class ImagePaste {
106
+ private var image: Data? // the real image, set aside
107
+ private var ourChange = -1 // the changeCount of the swap we made
108
+ private var counter = 0
109
+
110
+ private let folder = k0Home.appendingPathComponent("cache/images")
111
+
112
+ func start() {
113
+ try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
114
+ clearOld()
115
+ Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in self.tick() }
116
+ NSLog("k0: image pasting active (no permission required)")
117
+ }
118
+
119
+ private func tick() {
120
+ let pb = NSPasteboard.general
121
+ let inTerminal = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Terminal"
122
+
123
+ // The clipboard changed at somebody else's hand: read what is on it now.
124
+ if pb.changeCount != ourChange {
125
+ image = imageOnClipboard(pb)
126
+ ourChange = -1
127
+ }
128
+
129
+ guard let png = image else { return }
130
+
131
+ if inTerminal && ourChange == -1 {
132
+ // You come into Terminal with an image copied: the path goes in its place.
133
+ counter += 1
134
+ let file = folder.appendingPathComponent("clipboard-\(counter).png")
135
+ guard (try? png.write(to: file)) != nil else { return }
136
+ pb.clearContents()
137
+ pb.setString(file.path, forType: .string)
138
+ ourChange = pb.changeCount
139
+ } else if !inTerminal && ourChange != -1 {
140
+ // You have left Terminal: the image goes back where it was.
141
+ pb.clearContents()
142
+ pb.setData(png, forType: .png)
143
+ ourChange = pb.changeCount
144
+ }
145
+ }
146
+
147
+ /// Only when the clipboard really holds an image and nothing else: if there is text, it is
148
+ /// an ordinary paste and must not be touched.
149
+ private func imageOnClipboard(_ pb: NSPasteboard) -> Data? {
150
+ guard pb.string(forType: .string) == nil else { return nil }
151
+ if let png = pb.data(forType: .png) { return png }
152
+ guard let tiff = pb.data(forType: .tiff),
153
+ let rep = NSBitmapImageRep(data: tiff) else { return nil }
154
+ return rep.representation(using: .png, properties: [:])
155
+ }
156
+
157
+ /// Yesterday's images are of no use to anybody.
158
+ private func clearOld() {
159
+ let cutoff = Date().addingTimeInterval(-86400)
160
+ let f = FileManager.default
161
+ guard let files = try? f.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey])
162
+ else { return }
163
+ for u in files {
164
+ let date = (try? u.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
165
+ if let date, date < cutoff { try? f.removeItem(at: u) }
166
+ }
167
+ }
168
+ }
169
+
170
+ // MARK: - The app
171
+
172
+ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
173
+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
174
+ var seen: [Int: String] = [:] // the last known status of every card
175
+ var firstPass = true // no notifications on the first pass: that would be noise at startup
176
+ var notificationsAllowed = false
177
+ let images = ImagePaste()
178
+
179
+ func applicationDidFinishLaunching(_ n: Notification) {
180
+ item.button?.image = icon(nil)
181
+ item.menu = NSMenu()
182
+
183
+ let centre = UNUserNotificationCenter.current()
184
+ centre.delegate = self
185
+ centre.requestAuthorization(options: [.alert, .sound]) { ok, _ in
186
+ self.notificationsAllowed = ok
187
+ }
188
+
189
+ images.start()
190
+ watchScreens()
191
+ watchTerminal()
192
+
193
+ Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in self.refresh() }
194
+ refresh()
195
+ }
196
+
197
+ // MARK: The screen changing under the windows
198
+
199
+ /// You plug a monitor in or out, or change resolution, and the session windows stay where
200
+ /// they were: out of place, or off screen entirely. This hears the news and asks the server
201
+ /// to put them back.
202
+ ///
203
+ /// It falls to the menu bar icon because it is the only piece of k0 that sees that
204
+ /// notification: the server is plain Node, and to notice by itself it would have to
205
+ /// interrogate the screen constantly, which is a waste for something that happens twice a day.
206
+ func watchScreens() {
207
+ NotificationCenter.default.addObserver(
208
+ forName: NSApplication.didChangeScreenParametersNotification,
209
+ object: nil,
210
+ queue: .main
211
+ ) { _ in self.relayoutWindows() }
212
+ }
213
+
214
+ /// The debounce is not general caution: the notification arrives in bursts — plugging in a
215
+ /// monitor sends several in a row — and macOS takes a moment to settle the new arrangement
216
+ /// anyway. Moving on the first one would centre everything on the old screen.
217
+ private var screenTimer: Timer?
218
+ func relayoutWindows() {
219
+ screenTimer?.invalidate()
220
+ screenTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { _ in
221
+ guard let url = URL(string: "\(base)/api/windows/relayout") else { return }
222
+ var req = URLRequest(url: url)
223
+ req.httpMethod = "POST"
224
+ URLSession.shared.dataTask(with: req).resume()
225
+ }
226
+ }
227
+
228
+ // MARK: Terminal quitting
229
+
230
+ /// Shift+Enter starts a new line because the Terminal profile holds a key binding that sends
231
+ /// Esc+Enter (the installer writes it; the reason is over there). The point is **when** to
232
+ /// write it: Terminal reads those bindings only at startup, and on quitting it rewrites its
233
+ /// preferences from what it had in memory — erasing our entry. Written while Terminal is
234
+ /// running it is written for nothing; the only moment it survives is **right after Terminal
235
+ /// has died**, and from there until its next launch nobody touches it again.
236
+ ///
237
+ /// It falls to the menu bar icon for the same reason as the screens: it is the only piece of
238
+ /// k0 that sees applications leave. The real work stays in `k0-board shift-enter`, so the
239
+ /// logic about profiles and preferences is written in one place only.
240
+ func watchTerminal() {
241
+ NSWorkspace.shared.notificationCenter.addObserver(
242
+ forName: NSWorkspace.didTerminateApplicationNotification,
243
+ object: nil,
244
+ queue: .main
245
+ ) { n in
246
+ let app = n.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
247
+ guard app?.bundleIdentifier == "com.apple.Terminal" else { return }
248
+ self.restoreShiftEnter(in: 2)
249
+ }
250
+
251
+ // A Mac just switched on, or k0 installed with Terminal already closed: now is the good
252
+ // moment, and no notification is coming to tell us.
253
+ if !terminalRunning { restoreShiftEnter(in: 0) }
254
+ }
255
+
256
+ var terminalRunning: Bool {
257
+ NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.Terminal" }
258
+ }
259
+
260
+ private var shiftEnterInFlight = false
261
+
262
+ /// The wait is not general caution: Terminal, as it dies, is still flushing its preferences
263
+ /// to disk. Writing in the middle of that means being overwritten, which is exactly the
264
+ /// trouble this is running away from.
265
+ func restoreShiftEnter(in seconds: TimeInterval) {
266
+ guard !shiftEnterInFlight else { return }
267
+ shiftEnterInFlight = true
268
+
269
+ // <k0>/menubar/k0.app → two steps back and we are at <k0>/bin/k0-board.js.
270
+ let cli = Bundle.main.bundleURL
271
+ .deletingLastPathComponent()
272
+ .deletingLastPathComponent()
273
+ .appendingPathComponent("bin/k0-board.js")
274
+
275
+ // The installer puts the interpreter's real path in the service's environment, because
276
+ // under launchd the PATH is barely more than /usr/bin:/bin and a version manager's node
277
+ // would not be found at all.
278
+ let node = ProcessInfo.processInfo.environment["K0_NODE"] ?? "/usr/bin/env"
279
+ let args = node == "/usr/bin/env" ? ["node", cli.path, "shift-enter"] : [cli.path, "shift-enter"]
280
+
281
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + seconds) {
282
+ defer { DispatchQueue.main.async { self.shiftEnterInFlight = false } }
283
+
284
+ guard FileManager.default.isReadableFile(atPath: cli.path) else {
285
+ NSLog("k0: Shift+Enter, cannot find \(cli.path) — was the app moved out of k0?")
286
+ return
287
+ }
288
+ let p = Process()
289
+ p.executableURL = URL(fileURLWithPath: node)
290
+ p.arguments = args
291
+ do {
292
+ try p.run()
293
+ p.waitUntilExit()
294
+ NSLog("k0: Terminal quit, Shift+Enter put back in the profile (exit \(p.terminationStatus))")
295
+ } catch {
296
+ NSLog("k0: Shift+Enter not put back — \(error.localizedDescription)")
297
+ }
298
+ }
299
+ }
300
+
301
+ // MARK: Icon
302
+
303
+ /// A small rounded square like a sticky note, tinted with the most urgent status.
304
+ func icon(_ state: String?) -> NSImage {
305
+ let side: CGFloat = 14
306
+ let img = NSImage(size: NSSize(width: side, height: side))
307
+ img.lockFocus()
308
+ let r = NSBezierPath(roundedRect: NSRect(x: 1, y: 1, width: side - 2, height: side - 2),
309
+ xRadius: 2.5, yRadius: 2.5)
310
+ if state == nil {
311
+ // Nothing going on: outline only, so it does not catch the eye.
312
+ colour(nil).setStroke()
313
+ r.lineWidth = 1.5
314
+ r.stroke()
315
+ } else {
316
+ colour(state).setFill()
317
+ r.fill()
318
+ }
319
+ img.unlockFocus()
320
+ img.isTemplate = false
321
+ return img
322
+ }
323
+
324
+ // MARK: The refresh round
325
+
326
+ func refresh() {
327
+ guard let url = URL(string: "\(base)/api/status") else { return }
328
+ var req = URLRequest(url: url)
329
+ req.timeoutInterval = 3
330
+ URLSession.shared.dataTask(with: req) { data, _, _ in
331
+ let status: Status? = data.flatMap { try? JSONDecoder().decode(Status.self, from: $0) }
332
+ DispatchQueue.main.async { self.draw(status) }
333
+ }.resume()
334
+ }
335
+
336
+ func draw(_ status: Status?) {
337
+ // Server down: a dark icon and a menu that says so, instead of disappearing.
338
+ guard let status else {
339
+ item.button?.image = icon(nil)
340
+ item.button?.toolTip = "k0 — server not responding"
341
+ buildMenu(nil)
342
+ return
343
+ }
344
+
345
+ item.button?.image = icon(status.urgent)
346
+ item.button?.toolTip = status.waiting.isEmpty
347
+ ? "k0 — nothing waiting for you"
348
+ : "k0 — \(status.waiting.count) waiting"
349
+ buildMenu(status)
350
+ notifyNew(status)
351
+ }
352
+
353
+ // MARK: Menu
354
+
355
+ func buildMenu(_ status: Status?) {
356
+ let menu = NSMenu()
357
+
358
+ if status == nil {
359
+ let m = NSMenuItem(title: "Server not responding", action: nil, keyEquivalent: "")
360
+ m.isEnabled = false
361
+ menu.addItem(m)
362
+ } else if status!.waiting.isEmpty {
363
+ let m = NSMenuItem(title: "Nothing waiting for you", action: nil, keyEquivalent: "")
364
+ m.isEnabled = false
365
+ menu.addItem(m)
366
+ } else {
367
+ for w in status!.waiting {
368
+ let m = NSMenuItem(title: "\(w.title) · \(label[w.status] ?? w.status)",
369
+ action: #selector(goToSession(_:)), keyEquivalent: "")
370
+ m.target = self
371
+ m.tag = w.id
372
+ m.image = dot(w.status)
373
+ menu.addItem(m)
374
+ }
375
+ }
376
+
377
+ menu.addItem(.separator())
378
+ let board = NSMenuItem(title: "Dashboard", action: #selector(openBoard), keyEquivalent: "")
379
+ board.target = self
380
+ menu.addItem(board)
381
+ let restart = NSMenuItem(title: "Restart", action: #selector(restartServer), keyEquivalent: "")
382
+ restart.target = self
383
+ menu.addItem(restart)
384
+ let notifications = NSMenuItem(title: "Notifications", action: #selector(toggleNotifications), keyEquivalent: "")
385
+ notifications.target = self
386
+ notifications.state = Notifications.enabled ? .on : .off
387
+ menu.addItem(notifications)
388
+ // The four modes sit next to these because they are the same kind of gesture — something
389
+ // you leave as it is — but the tick comes from the server rather than from here: over
390
+ // there the board changes them too, and the menu is rebuilt every two seconds anyway.
391
+ // With the server down the items are left without an action, so macOS greys them out by
392
+ // itself: `isEnabled` would not be enough, automatic menu enabling takes it straight back.
393
+ //
394
+ // All this says is where we are: whether the mode is holding all the way to the closed
395
+ // lid is the board's story, which has the room to write it in words.
396
+ menu.addItem(.separator())
397
+ // With no answer from the server — or with a server older than this field — nothing is
398
+ // ticked: guessing a mode and showing it as true would be worse than saying nothing,
399
+ // because that is exactly the question the menu is there to answer.
400
+ let current = status.flatMap { $0.mode }
401
+ for (key, title) in Modes.all {
402
+ let entry = NSMenuItem(title: title,
403
+ action: status == nil ? nil : #selector(chooseMode(_:)),
404
+ keyEquivalent: "")
405
+ entry.target = self
406
+ entry.representedObject = key
407
+ entry.state = current == key ? .on : .off
408
+ menu.addItem(entry)
409
+ }
410
+
411
+ menu.addItem(.separator())
412
+ let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
413
+ quitItem.target = self
414
+ menu.addItem(quitItem)
415
+
416
+ item.menu = menu
417
+ }
418
+
419
+ func dot(_ state: String) -> NSImage {
420
+ let d: CGFloat = 9
421
+ let img = NSImage(size: NSSize(width: d, height: d))
422
+ img.lockFocus()
423
+ colour(state).setFill()
424
+ NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
425
+ img.unlockFocus()
426
+ return img
427
+ }
428
+
429
+ // MARK: Actions
430
+
431
+ @objc func goToSession(_ sender: NSMenuItem) { focus(sender.tag) }
432
+
433
+ func focus(_ id: Int) {
434
+ guard let url = URL(string: "\(base)/api/card/\(id)/focus") else { return }
435
+ var req = URLRequest(url: url)
436
+ req.httpMethod = "POST"
437
+ URLSession.shared.dataTask(with: req).resume()
438
+ }
439
+
440
+ @objc func toggleNotifications(_ sender: NSMenuItem) {
441
+ Notifications.enabled.toggle()
442
+ sender.state = Notifications.enabled ? .on : .off
443
+ }
444
+
445
+ /// The server holds the mode, so here we only ask. The tick moves straight away so the menu
446
+ /// is not left mute under your finger; the real one arrives with the next round, which is at
447
+ /// most two seconds later.
448
+ ///
449
+ /// Clicking the one already ticked does nothing: you leave a mode, you do not switch it off,
450
+ /// and there is always one lit.
451
+ @objc func chooseMode(_ sender: NSMenuItem) {
452
+ guard let chosen = sender.representedObject as? String, sender.state != .on else { return }
453
+ for entry in sender.menu?.items ?? [] where entry.representedObject is String {
454
+ entry.state = (entry.representedObject as? String) == chosen ? .on : .off
455
+ }
456
+ guard let url = URL(string: "\(base)/api/mode") else { return }
457
+ var req = URLRequest(url: url)
458
+ req.httpMethod = "POST"
459
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
460
+ req.httpBody = try? JSONSerialization.data(withJSONObject: ["mode": chosen])
461
+ URLSession.shared.dataTask(with: req) { _, _, _ in
462
+ DispatchQueue.main.async { self.refresh() }
463
+ }.resume()
464
+ }
465
+
466
+ @objc func openBoard() {
467
+ if let url = URL(string: boardURL) { NSWorkspace.shared.open(url) }
468
+ }
469
+
470
+ @objc func restartServer() {
471
+ let uid = getuid()
472
+ let p = Process()
473
+ p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
474
+ p.arguments = ["kickstart", "-k", "gui/\(uid)/com.k0.server"]
475
+ try? p.run()
476
+ }
477
+
478
+ @objc func quit() { NSApp.terminate(nil) }
479
+
480
+ // MARK: Notifications
481
+
482
+ /// Notifies only the MOVE into "Needs answer" or "Needs approval", and only when you are not
483
+ /// already looking at that very terminal.
484
+ func notifyNew(_ status: Status) {
485
+ var now: [Int: String] = [:]
486
+ for w in status.waiting { now[w.id] = w.status }
487
+
488
+ // The seen statuses are updated even with notifications off: switching them back on must
489
+ // not deliver everything that happened in the meantime all at once.
490
+ if !firstPass && Notifications.enabled {
491
+ for w in status.waiting where w.status == "ASK" || w.status == "PLANNED" {
492
+ guard seen[w.id] != w.status else { continue } // already notified
493
+ guard !amLookingAt(w.window) else { continue }
494
+ notify(w)
495
+ }
496
+ }
497
+ seen = now
498
+ firstPass = false
499
+ }
500
+
501
+ /// True when Terminal is the front application and its front window is this one.
502
+ func amLookingAt(_ window: String?) -> Bool {
503
+ guard NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Terminal" else {
504
+ return false
505
+ }
506
+ guard let window, let expected = Int(window) else { return false }
507
+ let p = Process()
508
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
509
+ p.arguments = ["-e", "tell application \"Terminal\" to get id of front window"]
510
+ let out = Pipe()
511
+ p.standardOutput = out
512
+ p.standardError = Pipe()
513
+ do { try p.run() } catch { return false }
514
+ p.waitUntilExit()
515
+ let text = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
516
+ return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) == expected
517
+ }
518
+
519
+ func notify(_ w: Waiting) {
520
+ let c = UNMutableNotificationContent()
521
+ c.title = "k0"
522
+ c.body = "\(w.title) · \(label[w.status] ?? w.status)"
523
+ c.userInfo = ["cardId": w.id]
524
+ c.sound = .default
525
+ let r = UNNotificationRequest(identifier: "k0-\(w.id)-\(w.status)", content: c, trigger: nil)
526
+ UNUserNotificationCenter.current().add(r) { error in
527
+ // If the Mac will not deliver this bundle's notifications, fall back to the system
528
+ // banner: it looks the same, but it is not clickable.
529
+ if error != nil || !self.notificationsAllowed { self.fallbackNotify(w) }
530
+ }
531
+ }
532
+
533
+ func fallbackNotify(_ w: Waiting) {
534
+ let text = "\(w.title) · \(label[w.status] ?? w.status)"
535
+ let p = Process()
536
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
537
+ p.arguments = ["-e", "display notification \"\(text)\" with title \"k0\""]
538
+ try? p.run()
539
+ }
540
+
541
+ // Clicking the notification goes straight to that terminal.
542
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
543
+ didReceive response: UNNotificationResponse,
544
+ withCompletionHandler completionHandler: @escaping () -> Void) {
545
+ if let id = response.notification.request.content.userInfo["cardId"] as? Int { focus(id) }
546
+ completionHandler()
547
+ }
548
+
549
+ // Show it even when k0 is "in the foreground" (it never really is, it is an accessory).
550
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
551
+ willPresent notification: UNNotification,
552
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
553
+ completionHandler([.banner, .sound])
554
+ }
555
+ }
556
+
557
+ let app = NSApplication.shared
558
+ let delegate = K0()
559
+ app.delegate = delegate
560
+ app.setActivationPolicy(.accessory) // no Dock icon, just the menu bar
561
+ app.run()
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # Builds k0.app: a minimal bundle, with no Xcode project.
3
+ set -e
4
+ cd "$(dirname "$0")"
5
+
6
+ APP="k0.app"
7
+ BIN="$APP/Contents/MacOS/k0"
8
+
9
+ # Rebuilding changes the signature, and macOS then makes you grant the Accessibility
10
+ # permission all over again. So it only recompiles when the code has really changed.
11
+ if [ -x "$BIN" ] && [ "$BIN" -nt K0MenuBar.swift ] && [ "$BIN" -nt Info.plist ]; then
12
+ echo "✅ $(pwd)/$APP (already up to date)"
13
+ exit 0
14
+ fi
15
+
16
+ rm -rf "$APP"
17
+ mkdir -p "$APP/Contents/MacOS"
18
+ cp Info.plist "$APP/Contents/Info.plist"
19
+
20
+ # -swift-version 5: Swift 6's strict concurrency is not worth it for a 400-line app.
21
+ swiftc -O -swift-version 5 -o "$BIN" K0MenuBar.swift
22
+
23
+ # Ad-hoc signature: without one, macOS does not deliver the bundle's notifications.
24
+ codesign --force --sign - --identifier com.k0.menubar "$APP" >/dev/null 2>&1 || {
25
+ echo "⚠️ signing failed: notifications will use the fallback banner"
26
+ }
27
+
28
+ echo "✅ $(pwd)/$APP"
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "k0-board",
3
+ "version": "0.1.0",
4
+ "description": "A board of sticky notes that drives your Claude Code sessions",
5
+ "keywords": [
6
+ "claude-code",
7
+ "ai-coding",
8
+ "kanban",
9
+ "developer-tools",
10
+ "terminal",
11
+ "dashboard"
12
+ ],
13
+ "homepage": "https://github.com/alessio-ragni/k0-board#readme",
14
+ "bugs": "https://github.com/alessio-ragni/k0-board/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/alessio-ragni/k0-board.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Alessio Ragni (https://alessioragni.com)",
21
+ "type": "module",
22
+ "bin": {
23
+ "k0-board": "bin/k0-board.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "platform",
31
+ "server",
32
+ "web",
33
+ "menubar",
34
+ "!menubar/k0.app",
35
+ ".claude/skills",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "start": "node server/index.js",
42
+ "test": "node test/clean.test.mjs && node test/states.test.mjs && node test/files.test.mjs && node test/mentions.test.mjs && node test/refs.test.mjs && node test/machine.test.mjs && node test/mode.test.mjs",
43
+ "doctor": "node bin/k0-board.js doctor"
44
+ }
45
+ }