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.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
package/server/index.js
ADDED
|
@@ -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
|
+
}
|