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,636 @@
1
+ import http from 'node:http'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import * as db from './db.js'
6
+ import { ROOT } from './paths.js'
7
+ import { readLiveSessions, deriveStatus, forgetSession, renameSession } from './watcher.js'
8
+ import {
9
+ launch,
10
+ focusWindow,
11
+ sessionName,
12
+ closeTerminal,
13
+ setWindowTitle,
14
+ setTerminalFont,
15
+ relayoutWindows,
16
+ } from './launcher.js'
17
+ import * as mode from './mode.js'
18
+ import { listProjects, onDisk, projectName } from './projects.js'
19
+ import { scanSessions } from './sessions.js'
20
+ import * as git from './git.js'
21
+ import * as files from './files.js'
22
+ import * as pdf from './pdf.js'
23
+ import * as machine from './machine.js'
24
+ import { report as platformReport } from '../platform/index.js'
25
+ // The same front-matter reader the viewer uses: the PDF has to be called what the page is
26
+ // called, and two different readings of the same header would give two different names.
27
+ import { matter, titleOf } from '../web/md.js'
28
+
29
+ const WEB = path.join(ROOT, 'web')
30
+ const PORT = Number(process.env.K0_PORT || 4319)
31
+
32
+ // ── The watching loop ────────────────────────────────────────────────────────
33
+ // Runs with the tab closed too, so the history of statuses has no holes.
34
+ let rounds = 0
35
+ function tick() {
36
+ const live = readLiveSessions()
37
+ const dirs = new Set()
38
+ for (const card of db.listCards()) {
39
+ const { status, alive } = deriveStatus(card, live)
40
+ // The session has just gone out: now that nobody is rewriting the transcript any more, we
41
+ // put in it the name the card has today. That is how a rename made while the session was
42
+ // running still reaches the list of sessions you can resume.
43
+ if (card.session_id && card.session_alive && !alive) {
44
+ renameSession(card.project_path, card.session_id, sessionName(card.title))
45
+ }
46
+ db.applyDerivedStatus(card.id, status, alive)
47
+
48
+ // Where it is really working: with an isolated worktree that is not the repository, and a
49
+ // worktree has a working tree of its own. Claude Code's session file tells us, the same
50
+ // source the statuses come from. It is kept after the session ends too: if the worktree is
51
+ // still sitting there with work in it, the card has to be able to say so.
52
+ const cwd = card.session_id ? live.get(card.session_id)?.cwd : null
53
+ if (cwd && cwd !== card.work_path) db.setWorkPath(card.id, cwd)
54
+ dirs.add(card.project_path)
55
+ dirs.add(cwd || card.work_path)
56
+ }
57
+ // The last column lists every repository with its git mark. They are only looked at while
58
+ // the board is open, and slowly: see `watch` in git.js.
59
+ git.watch([...dirs], Date.now() - lastBoard < 15000 ? listProjects().map((p) => p.path) : [])
60
+
61
+ // The power levers are checked once a minute rather than every round: reading the power
62
+ // state costs processes, and the things it has to follow — the mains plugged in or out, the
63
+ // battery going down — do not change from one second to the next.
64
+ if (rounds++ % 60 === 0) mode.guard()
65
+ }
66
+
67
+ /** When somebody last looked at the board. */
68
+ let lastBoard = 0
69
+ setInterval(tick, 1000)
70
+
71
+ // ── Who is allowed to talk to this server ────────────────────────────────────
72
+ // k0 listens on the loopback interface only, which keeps the rest of the network out. What it
73
+ // does not keep out is a web page open in your browser: any site can make requests to
74
+ // 127.0.0.1, and this API opens terminals and starts sessions.
75
+ //
76
+ // Two checks close that door.
77
+ //
78
+ // The `Host` header must be one we recognise. A page cannot forge it, and requiring it stops
79
+ // DNS rebinding — the trick where a hostile domain resolves to 127.0.0.1 so the browser thinks
80
+ // it is talking to its own site.
81
+ //
82
+ // The `Origin` header, when there is one, must be ours. Browsers attach it to every
83
+ // cross-origin request and to every same-origin POST, so a hostile page is always identified.
84
+ // Things that are not browsers — the menu bar icon, curl, the import skill — send none, and
85
+ // they are let through: they are already running as you, on your machine, and a header would
86
+ // not tell us anything a hostile process could not also write.
87
+ const ALLOWED_HOSTS = new Set(
88
+ ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `${h}:${PORT}`)
89
+ )
90
+ const ALLOWED_ORIGINS = new Set(
91
+ ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `http://${h}:${PORT}`)
92
+ )
93
+
94
+ function allowed(req) {
95
+ const host = String(req.headers.host || '')
96
+ if (!ALLOWED_HOSTS.has(host)) return false
97
+ const origin = req.headers.origin
98
+ if (origin && !ALLOWED_ORIGINS.has(origin)) return false
99
+ return true
100
+ }
101
+
102
+ // ── API ──────────────────────────────────────────────────────────────────────
103
+ const MIME = {
104
+ '.html': 'text/html; charset=utf-8',
105
+ '.css': 'text/css; charset=utf-8',
106
+ '.js': 'text/javascript; charset=utf-8',
107
+ '.svg': 'image/svg+xml',
108
+ '.json': 'application/json; charset=utf-8',
109
+ '.png': 'image/png',
110
+ '.woff2': 'font/woff2',
111
+ '.woff': 'font/woff',
112
+ '.ttf': 'font/ttf',
113
+ }
114
+
115
+ const send = (res, code, body) => {
116
+ const json = JSON.stringify(body)
117
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
118
+ res.end(json)
119
+ }
120
+
121
+ const clamp = (raw, fallback, min, max) => {
122
+ const n = Math.floor(Number(raw))
123
+ return Number.isFinite(n) && n > 0 ? Math.min(max, Math.max(min, n)) : fallback
124
+ }
125
+
126
+ const readBody = (req) =>
127
+ new Promise((resolve, reject) => {
128
+ let raw = ''
129
+ req.on('data', (c) => {
130
+ raw += c
131
+ if (raw.length > 1e6) reject(new Error('Request body too large'))
132
+ })
133
+ req.on('end', () => {
134
+ try {
135
+ resolve(raw ? JSON.parse(raw) : {})
136
+ } catch (e) {
137
+ reject(e)
138
+ }
139
+ })
140
+ })
141
+
142
+ /** The list of unpushed commits is only needed in here: the board just wants the count. */
143
+ const publicGit = (s) => (s ? { branch: s.branch, dirty: s.dirty, unpushed: s.unpushed, remote: s.remote } : null)
144
+
145
+ /**
146
+ * A card's git state. A session with a worktree of its own has a working tree of its own, and
147
+ * that is its truth; the others share the repository's working tree, which belongs to
148
+ * everybody — there, the only thing that really belongs to the session is the commits made
149
+ * since it started, and those are counted from `head_at_start`.
150
+ */
151
+ function cardGit(card) {
152
+ const own = card.work_path && card.work_path !== card.project_path ? git.stateOf(card.work_path) : null
153
+ if (own) return { ...publicGit(own), own: true, where: path.basename(card.work_path), mine: own.unpushed }
154
+ const repo = git.stateOf(card.project_path)
155
+ if (!repo) return null
156
+ return { ...publicGit(repo), own: false, where: null, mine: git.sessionShare(repo.shas, card.head_at_start) }
157
+ }
158
+
159
+ function board() {
160
+ // Looking at the board is the only thing that switches the load measurement on: reading the
161
+ // process table costs, and with the tab closed nobody would be looking. See machine.js.
162
+ machine.touch()
163
+ lastBoard = Date.now()
164
+ const live = readLiveSessions()
165
+
166
+ const cards = onDisk(db.listCards()).map((c) => ({
167
+ ...c,
168
+ project_name: projectName(c.project_path),
169
+ git: cardGit(c),
170
+ load: machine.loadOf(live.get(c.session_id)?.pid),
171
+ }))
172
+ const paths = [...new Set(cards.map((c) => c.project_path))]
173
+
174
+ // Live sessions no card claims: they weigh the same, and if the computer is struggling it is
175
+ // fair to know there are three of them open outside here.
176
+ const onBoard = new Set(cards.map((c) => c.session_id).filter(Boolean))
177
+ const outside = [...live.keys()].filter((sid) => !onBoard.has(sid)).length
178
+
179
+ return {
180
+ // The columns are the projects with at least one card — and that still exist: a card is
181
+ // kept alive by its directory (see `onDisk` in projects.js).
182
+ columns: paths
183
+ .map((p) => ({ path: p, name: projectName(p), git: publicGit(git.stateOf(p)) }))
184
+ .sort((a, b) => a.name.localeCompare(b.name)),
185
+ cards,
186
+ // The repositories for the last column, the one you go through to reach the files of a
187
+ // directory that has no cards at all. Real projects only: the directories under your home
188
+ // that are just installed software do not belong (see `known` in projects.js).
189
+ projects: listProjects()
190
+ .filter((p) => p.known)
191
+ .map((p) => ({ ...p, git: publicGit(git.stateOf(p.path)) })),
192
+ machine: { ...machine.overview([...live.values()].map((s) => s.pid)), alive: live.size, outside },
193
+ // What colour the icon in the tab should be: the same thing /api/status tells the icon in
194
+ // the menu bar.
195
+ urgent: attention(cards).urgent,
196
+ // Which mode we are in. It travels in here rather than on an address of its own because
197
+ // the board already makes this round every second: a second address to poll would only be
198
+ // another way of finding out the same thing later.
199
+ //
200
+ // `mode` is what you want; `lid` is what the machine is actually doing. They are two
201
+ // different things — a mode can be on without the lid block holding — and the board has to
202
+ // be able to tell you both.
203
+ mode: mode.current(),
204
+ lid: mode.lid(),
205
+ reason: mode.reason(),
206
+ // What this machine can and cannot do, so the board greys out the rest and says why
207
+ // instead of offering a button that quietly does nothing.
208
+ platform: platformReport(),
209
+ now: Date.now(),
210
+ }
211
+ }
212
+
213
+ // Attention order, the same one that sorts the board's columns.
214
+ const ORDER = ['ASK', 'PLANNED', 'IDLE', 'WORKING', 'PLANNING', 'BACKLOG', 'COMPLETED']
215
+ const WAITING = new Set(['ASK', 'PLANNED', 'IDLE'])
216
+
217
+ /**
218
+ * The little the menu bar icon needs: what is waiting for you, and what colour it should turn.
219
+ * It is separate from /api/board because it is asked for every two seconds.
220
+ */
221
+ function status() {
222
+ const { urgent, waiting } = attention(onDisk(db.listCards()))
223
+ return {
224
+ urgent,
225
+ waiting: waiting.map((c) => ({
226
+ id: c.id,
227
+ title: c.title,
228
+ project: projectName(c.project_path),
229
+ status: c.status,
230
+ window: c.terminal_window_id,
231
+ })),
232
+ // Which of the four entries to tick in the menu. It lives here and not in the icon's own
233
+ // preferences, unlike notifications: it is the same control that is on the board, and two
234
+ // separate copies of the same thing would end up disagreeing. The menu only needs the
235
+ // tick: why a mode is not holding with the lid closed is the board's story to tell, which
236
+ // has the room to say it in words.
237
+ mode: mode.current(),
238
+ }
239
+ }
240
+
241
+ /**
242
+ * What is waiting for you, most urgent first. It is the rule that decides the colour of the
243
+ * icon, and the icon is now in two places — the menu bar and the browser tab. So the rule is
244
+ * written once.
245
+ */
246
+ function attention(all) {
247
+ const cards = all.filter((c) => c.session_id && c.session_alive && !c.completed_at)
248
+ const waiting = cards
249
+ .filter((c) => WAITING.has(c.status))
250
+ .sort((a, b) => ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || (a.status_since ?? 0) - (b.status_since ?? 0))
251
+ // If nothing is waiting but something is running, the icon still stays lit.
252
+ const busy = cards.find((c) => c.status === 'WORKING' || c.status === 'PLANNING')
253
+ return { urgent: waiting[0]?.status ?? busy?.status ?? null, waiting }
254
+ }
255
+
256
+ /**
257
+ * The directories the viewer is allowed to open: the repositories k0 already knows, plus the
258
+ * ones sessions are really working in — which with a worktree are not the same thing. A path
259
+ * arriving from the address bar is not enough on its own: either it is in this list, or
260
+ * nothing is read.
261
+ */
262
+ function rootOf(repo) {
263
+ if (!repo) return null
264
+ const roots = new Set(listProjects().map((p) => p.path))
265
+ for (const c of db.listCards()) {
266
+ if (c.project_path) roots.add(c.project_path)
267
+ if (c.work_path) roots.add(c.work_path)
268
+ }
269
+ return roots.has(repo) ? repo : null
270
+ }
271
+
272
+ /**
273
+ * A file served with its real path in the address, so that whatever the page asks for next to
274
+ * itself is actually found. The repository is the first piece, with its slashes disguised, and
275
+ * from there on it is the path inside the repository.
276
+ */
277
+ function site(req, res, seg) {
278
+ let repo, rel
279
+ try {
280
+ repo = decodeURIComponent(seg[2] || '')
281
+ rel = seg.slice(3).map(decodeURIComponent).join('/')
282
+ } catch {
283
+ return send(res, 400, { error: 'That address does not mean anything' })
284
+ }
285
+ const root = rootOf(repo)
286
+ const abs = root && rel ? files.safePath(root, rel) : null
287
+ if (!abs) return send(res, 400, { error: 'That file is outside the repository' })
288
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 404, { error: 'File not found' })
289
+
290
+ const head = {
291
+ 'content-type': files.mimeOf(abs),
292
+ 'content-length': fs.statSync(abs).size,
293
+ 'cache-control': 'no-store',
294
+ }
295
+ if (files.isPage(abs)) head['content-security-policy'] = SANDBOX
296
+ res.writeHead(200, head)
297
+ return fs.createReadStream(abs).pipe(res)
298
+ }
299
+
300
+ // A page that comes off the disk is text nobody has read, and letting it run would make it a
301
+ // resident here: from inside it could call k0's own API, which opens terminals and starts
302
+ // sessions. Shut like this it executes nothing and has an origin of its own, so from in there
303
+ // it touches nothing. It shows — which is what is wanted — but it does not act.
304
+ //
305
+ // The cost is fonts. Inside the sandbox the page's origin is opaque, so anything it loads from
306
+ // this server is a cross-origin request, and without a CORS header the browser refuses it. k0
307
+ // used to send `access-control-allow-origin: *` for exactly that reason, and that header also
308
+ // let any site on the internet read files out of your repositories. A web font that falls back
309
+ // is a much smaller loss than that, so the header is gone.
310
+ const SANDBOX = 'sandbox'
311
+
312
+ async function api(req, res, url) {
313
+ const seg = url.pathname.split('/').filter(Boolean) // ['api', 'card', '3', 'start']
314
+ const [, resource, idRaw, action] = seg
315
+ const id = Number(idRaw)
316
+
317
+ if (resource === 'board' && req.method === 'GET') return send(res, 200, board())
318
+ if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
319
+ if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
320
+
321
+ // ── The four modes ─────────────────────────────────────────────────────────
322
+ // One control, with two handles: the row of buttons on the board and the four entries in the
323
+ // menu bar. The text in terminals that are already open changes STRAIGHT AWAY, not only in
324
+ // the ones you open next — otherwise, to be able to read anything, you would have to close
325
+ // everything and start again.
326
+ //
327
+ // The answer does not only say which mode we are in: it says whether it is holding. Whoever
328
+ // clicked has to know now whether the lid is covered, not in a second's time.
329
+ if (resource === 'mode' && req.method === 'POST') {
330
+ const b = await readBody(req)
331
+ await mode.setMode(b.mode)
332
+ await setTerminalFont(db.listCards().map((c) => c.terminal_window_id))
333
+ return send(res, 200, { mode: mode.current(), lid: mode.lid(), reason: mode.reason() })
334
+ }
335
+
336
+ // The screen changed under the windows: a monitor plugged in or unplugged, a different
337
+ // resolution. This puts them all back in the middle of what is there now, as they were the
338
+ // day they were born. The menu bar icon calls it, being the one piece of k0 that sees that
339
+ // news arrive.
340
+ if (resource === 'windows' && idRaw === 'relayout' && req.method === 'POST') {
341
+ return send(res, 200, await relayoutWindows(db.listCards().map((c) => c.terminal_window_id)))
342
+ }
343
+
344
+ // ── A page as a page ───────────────────────────────────────────────────────
345
+ // An `.html` is looked at, not read as source, and to really look at it, it has to bring its
346
+ // things along: the stylesheet next to it, the images in the directory below. Those are
347
+ // written as relative paths, and they resolve against the address — which is why here the
348
+ // file's path is the address's path, and not a parameter on the end as it is for everything
349
+ // else. The repository goes in front, packed into one piece, and goes through the same guard
350
+ // as all the rest.
351
+ if (resource === 'site' && req.method === 'GET') return site(req, res, seg)
352
+
353
+ // ── Sessions that already happened ─────────────────────────────────────────
354
+ // The ones opened outside k0, or before k0 existed: they can be looked at and imported.
355
+ if (resource === 'sessions' && idRaw === 'candidates' && req.method === 'GET') {
356
+ const days = clamp(url.searchParams.get('days'), 14, 1, 365)
357
+ const perRepo = clamp(url.searchParams.get('per_repo'), 10, 1, 100)
358
+ return send(
359
+ res,
360
+ 200,
361
+ scanSessions({
362
+ days,
363
+ perRepo,
364
+ exclude: new Set(db.sessionIds()),
365
+ live: new Set(readLiveSessions().keys()),
366
+ })
367
+ )
368
+ }
369
+
370
+ if (resource === 'sessions' && idRaw === 'import' && req.method === 'POST') {
371
+ const b = await readBody(req)
372
+ const items = Array.isArray(b.items) ? b.items : []
373
+ // Duplicates are dropped here and not in the database: `card.session_id` has no uniqueness
374
+ // constraint, and this is not the moment to add one to a database that already has data.
375
+ const taken = new Set(db.sessionIds())
376
+ const cards = []
377
+ const skipped = []
378
+ for (const it of items) {
379
+ const raw = String(it.title || '').trim()
380
+ const sessionId = String(it.session_id || '')
381
+ if (!sessionId || !it.project_path || !raw) {
382
+ skipped.push({ session_id: sessionId || null, why: 'session, repository and title are all required' })
383
+ continue
384
+ }
385
+ // The same normalisation as the title field on the dashboard: a card's title is the
386
+ // session's name, and importing must not create one of a different shape.
387
+ const title = sessionName(raw)
388
+ if (taken.has(sessionId)) {
389
+ skipped.push({ session_id: sessionId, why: 'already on the board' })
390
+ continue
391
+ }
392
+ taken.add(sessionId)
393
+ cards.push(
394
+ db.importCard({
395
+ title,
396
+ description: String(it.description || '').trim(),
397
+ project_path: it.project_path,
398
+ session_id: sessionId,
399
+ started_at: it.started_at,
400
+ ended_at: it.ended_at,
401
+ })
402
+ )
403
+ }
404
+ // A session that is still running takes on the colour of what it is doing right away.
405
+ tick()
406
+ return send(res, 200, { created: cards.length, skipped, cards })
407
+ }
408
+
409
+ // ── A repository's files ───────────────────────────────────────────────────
410
+ // The whole listing is big and changes slowly; what changes quickly is the files that have
411
+ // been touched. Which is why `only=changed` exists: that is what the page asks for every
412
+ // three seconds, while it re-reads the complete listing rarely.
413
+ //
414
+ // Which of the names written inside a document are real files. It is only reached for the
415
+ // ones the listing does not already have — most of the time it does not run at all — and it
416
+ // is needed because a directory the listing skips, like `out/`, can hold the very documents
417
+ // that text is naming.
418
+ if (resource === 'files' && idRaw === 'exist' && req.method === 'POST') {
419
+ const b = await readBody(req)
420
+ const root = rootOf(b.repo)
421
+ if (!root) return send(res, 400, { error: 'Unknown repository' })
422
+ return send(res, 200, { paths: files.exist(root, b.paths) })
423
+ }
424
+
425
+ if (resource === 'files' && req.method === 'GET') {
426
+ const root = rootOf(url.searchParams.get('repo'))
427
+ if (!root) return send(res, 400, { error: 'Unknown repository' })
428
+ // The card is only there to say where the session started from, and it can be missing:
429
+ // from a column you open the repository's files and that is all.
430
+ const cardId = Number(url.searchParams.get('card'))
431
+ const card = Number.isInteger(cardId) && cardId > 0 ? db.getCard(cardId) : null
432
+ // Searching inside the files: the page asks for it as you type, and it needs neither the
433
+ // listing nor git's state.
434
+ if (url.searchParams.get('only') === 'text') {
435
+ return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '') })
436
+ }
437
+ const changed = await files.changed(root, card?.head_at_start)
438
+ if (url.searchParams.get('only') === 'changed') return send(res, 200, { changed, now: Date.now() })
439
+ const all = await files.list(root)
440
+ return send(res, 200, {
441
+ root,
442
+ name: projectName(root),
443
+ title: card?.title ?? null,
444
+ git: all.git,
445
+ truncated: all.truncated,
446
+ changed,
447
+ files: all.files,
448
+ now: Date.now(),
449
+ })
450
+ }
451
+
452
+ if (resource === 'file') {
453
+ // `reveal` carries the path in its body the way the other two carry it in the query: it is
454
+ // read from wherever it arrives and then they all go through the same guard.
455
+ const body = req.method === 'POST' ? await readBody(req) : null
456
+ const repo = body ? body.repo : url.searchParams.get('repo')
457
+ const rel = body ? body.path : url.searchParams.get('path')
458
+ const root = rootOf(repo)
459
+ const abs = root && rel ? files.safePath(root, rel) : null
460
+ if (!abs) return send(res, 400, { error: 'That file is outside the repository' })
461
+ if (!fs.existsSync(abs)) return send(res, 404, { error: 'File not found' })
462
+
463
+ // The bytes as they are: that is how images and PDFs get seen. With `dl`, the same bytes
464
+ // but announced to the browser as an attachment: the button that carries the file away
465
+ // exactly as it is.
466
+ if (idRaw === 'raw' && req.method === 'GET') {
467
+ const size = fs.statSync(abs).size
468
+ const head = { 'content-type': files.mimeOf(abs), 'content-length': size, 'cache-control': 'no-store' }
469
+ if (url.searchParams.get('dl')) head['content-disposition'] = files.attachment(path.basename(abs))
470
+ else if (files.isPage(abs)) head['content-security-policy'] = SANDBOX
471
+ res.writeHead(200, head)
472
+ return fs.createReadStream(abs).pipe(res)
473
+ }
474
+
475
+ // The document laid out on paper. Not the browser's print dialog: a finished file, which
476
+ // lands in your downloads and can be sent to somebody.
477
+ if (idRaw === 'pdf' && req.method === 'GET') {
478
+ const file = files.read(abs)
479
+ if (file.kind !== 'markdown' && file.kind !== 'text') {
480
+ return send(res, 400, { error: 'Only documents come out on paper' })
481
+ }
482
+ const page = `http://127.0.0.1:${PORT}/files.html?${new URLSearchParams({ repo: root, f: rel, pdf: '1' })}`
483
+ let out
484
+ try {
485
+ out = await pdf.render(page)
486
+ } catch (e) {
487
+ return send(res, 500, { error: e.message })
488
+ }
489
+ // The same name the viewer puts on the page: the title written in the front matter, and
490
+ // only failing that the file's name — without its extension, which here is `.pdf`, and
491
+ // nobody wants two extensions stuck together.
492
+ const named = file.kind === 'markdown' ? titleOf(matter(file.text).data) : null
493
+ const base = path.basename(abs, path.extname(abs))
494
+ res.writeHead(200, {
495
+ 'content-type': 'application/pdf',
496
+ 'content-length': out.length,
497
+ 'cache-control': 'no-store',
498
+ 'content-disposition': files.attachment(`${named || base}.pdf`),
499
+ })
500
+ return res.end(out)
501
+ }
502
+
503
+ if (idRaw === 'reveal' && req.method === 'POST') {
504
+ await files.reveal(abs)
505
+ return send(res, 200, { ok: true })
506
+ }
507
+ if (!idRaw && req.method === 'GET') return send(res, 200, { path: rel, ...files.read(abs) })
508
+ }
509
+
510
+ if (resource === 'card' && req.method === 'POST' && !idRaw) {
511
+ const b = await readBody(req)
512
+ if (!b.title?.trim() || !b.project_path) return send(res, 400, { error: 'Title and repository are required' })
513
+ return send(res, 200, db.createCard({ ...b, title: b.title.trim() }))
514
+ }
515
+
516
+ if (resource === 'card' && id) {
517
+ const card = db.getCard(id)
518
+ if (!card) return send(res, 404, { error: 'Card not found' })
519
+
520
+ if (req.method === 'PATCH') {
521
+ const b = await readBody(req)
522
+ let updated = db.patchCard(id, b)
523
+ // Done closes the terminal too, and it does it BEFORE marking the card: finished work
524
+ // must not leave behind a window k0 has stopped watching.
525
+ if (b.completed === true && card.session_id) {
526
+ await closeTerminal({ winId: card.terminal_window_id, pid: readLiveSessions().get(card.session_id)?.pid })
527
+ }
528
+ if ('completed' in b) updated = db.setCompleted(id, !!b.completed)
529
+ // A card's name is the session's name: if the title changes — and only the title, the
530
+ // prompt has nothing to do with it — it has to change over there too.
531
+ if (card.session_id && sessionName(updated.title) !== sessionName(card.title)) {
532
+ const name = sessionName(updated.title)
533
+ // In the window's title bar it shows immediately, session running or not.
534
+ await setWindowTitle(card.terminal_window_id, name)
535
+ // In the transcript only once the session has ended: a live process would write the
536
+ // old name back over it. `tick` takes care of that when the session dies. The path is
537
+ // the one from BEFORE the change: that is where the transcript stayed, even if you
538
+ // changed repository in the same move.
539
+ if (!card.session_alive) renameSession(card.project_path, card.session_id, name)
540
+ }
541
+ return send(res, 200, updated)
542
+ }
543
+
544
+ if (req.method === 'DELETE') {
545
+ if (card.session_id) forgetSession(card.session_id)
546
+ db.deleteCard(id)
547
+ return send(res, 200, { ok: true })
548
+ }
549
+
550
+ // Bringing this session's window back to the front: the double click on the card.
551
+ if (req.method === 'POST' && action === 'focus') {
552
+ return send(res, 200, await focusWindow(card.terminal_window_id))
553
+ }
554
+
555
+ if (req.method === 'POST' && (action === 'start' || action === 'resume')) {
556
+ const resuming = action === 'resume' && card.session_id
557
+ const sessionId = resuming ? card.session_id : crypto.randomUUID()
558
+ if (!resuming) {
559
+ if (card.session_id) forgetSession(card.session_id)
560
+ db.attachSession(id, sessionId)
561
+ }
562
+ // From here on, this repository's commits belong to this session. Resuming does not
563
+ // rewrite it: the starting mark stays the first one, otherwise the commits of the first
564
+ // run would lose their owner. It goes before `launch`, which can be away for as long as
565
+ // forty-five seconds.
566
+ if (!resuming || !card.head_at_start) db.setHeadAtStart(id, await git.head(card.project_path))
567
+ try {
568
+ const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
569
+ db.setTerminalWindow(id, out.winId)
570
+ tick()
571
+ return send(res, 200, { ...out, card: db.getCard(id) })
572
+ } catch (err) {
573
+ // The terminal did not open: the card must not stay attached to a session that was
574
+ // never born, or it would offer a "resume" that cannot work.
575
+ if (!resuming) db.detachSession(id, card.session_id)
576
+ return send(res, 500, { error: String(err.message || err) })
577
+ }
578
+ }
579
+ }
580
+
581
+ return send(res, 404, { error: 'No such endpoint' })
582
+ }
583
+
584
+ // ── Static files ─────────────────────────────────────────────────────────────
585
+ function serveStatic(res, pathname) {
586
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
587
+ const file = path.join(WEB, rel)
588
+ if (!file.startsWith(WEB) || !fs.existsSync(file)) {
589
+ res.writeHead(404).end('Not found')
590
+ return
591
+ }
592
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' })
593
+ fs.createReadStream(file).pipe(res)
594
+ }
595
+
596
+ http
597
+ .createServer(async (req, res) => {
598
+ if (!allowed(req)) {
599
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
600
+ res.end('k0 only answers to its own address on this machine.')
601
+ return
602
+ }
603
+ const url = new URL(req.url, `http://${req.headers.host}`)
604
+ try {
605
+ if (url.pathname.startsWith('/api/')) return await api(req, res, url)
606
+ serveStatic(res, url.pathname)
607
+ } catch (err) {
608
+ send(res, 500, { error: String(err.message || err) })
609
+ }
610
+ })
611
+ .listen(PORT, '127.0.0.1', () => {
612
+ tick()
613
+ // The mode is remembered, and at startup it puts the machine back as it was: the sleep
614
+ // levers and, where needed, the font size of terminals left open. The server restarts often
615
+ // — just working on k0's own code is enough — and the windows from before do not notice:
616
+ // applying the mode REPAIRS that mismatch instead of adopting it.
617
+ mode.start().then(() => setTerminalFont(db.listCards().map((c) => c.terminal_window_id)))
618
+ console.log(`k0 — dashboard on http://localhost:${PORT}`)
619
+ })
620
+
621
+ // ── On the way out ───────────────────────────────────────────────────────────
622
+ // The first and only orderly shutdown this server has ever had, and it exists for one reason:
623
+ // a sleep block is system state, not something of ours held in memory. If k0 goes away while
624
+ // it is on, the machine never sleeps again — forever, with nobody knowing why. So whichever
625
+ // road the exit takes, it is released; the preference is left as it is and next time
626
+ // `mode.start()` puts everything back.
627
+ //
628
+ // `exit` alone is not enough: a service manager stops the server with a SIGTERM, which without
629
+ // a handler of its own skips the orderly exit and never comes through here.
630
+ process.on('exit', mode.stopNow)
631
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
632
+ process.on(signal, () => {
633
+ mode.stopNow()
634
+ process.exit(0)
635
+ })
636
+ }