k0-board 0.1.1 → 0.2.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/server/git.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { run, which } from '../platform/shared/run.js'
4
+ // The same reading of `status --porcelain=v2 -z` the viewer already does. One parser for one
5
+ // output format: two would drift, and the day they disagreed nobody would notice.
6
+ import { parseChanged } from './files.js'
4
7
 
5
8
  // ── Git ───────────────────────────────────────────────────────────────────────
6
9
  // The only place that talks to git. It exists to answer one question: is what is in here
@@ -155,3 +158,135 @@ export async function head(dir) {
155
158
  .then((out) => out.trim() || null)
156
159
  .catch(() => null)
157
160
  }
161
+
162
+ // ── The history ───────────────────────────────────────────────────────────────
163
+ // Everything above answers "is what is in here safe right now". What follows answers a
164
+ // different question — "what did I do, and did it get out" — and it is the only part that
165
+ // reads the commits themselves instead of counting them.
166
+ //
167
+ // Still no fetch. `--not --remotes` compares against the remote references this machine
168
+ // already holds, and those are written by the push itself: for "did it leave here" that is
169
+ // exact, and it costs nothing.
170
+
171
+ const LOG_OPTS = { timeout: 15000, maxBuffer: 1 << 24 } // a month of a busy repository
172
+ const MAX_LOG = 400 // past that nobody reads the list, and the count already tells the story
173
+ const MAX_DIRTY = 60
174
+
175
+ // Two separators git will never find inside a commit message, so a subject with a quote in it
176
+ // or a body with a tab still comes out in one piece.
177
+ const FIELD = '\x1f'
178
+ const RECORD = '\x1e'
179
+ const FORMAT = `%H${FIELD}%aI${FIELD}%s${FIELD}%b${RECORD}`
180
+
181
+ /**
182
+ * The commits out of `log --format=…`, newest first, in the order git gave them.
183
+ * An empty body is not a failure: most commits here are a single line and nothing else.
184
+ */
185
+ export function parseLog(out) {
186
+ const commits = []
187
+ for (const record of String(out ?? '').split(RECORD)) {
188
+ const r = record.replace(/^\s+/, '')
189
+ if (!r) continue
190
+ const [sha, at, subject, body] = r.split(FIELD)
191
+ if (!sha || !subject) continue
192
+ commits.push({ sha, at, subject, body: stripTrailers(body) })
193
+ }
194
+ return commits
195
+ }
196
+
197
+ // The lines at the foot of a commit that are addressed to git and to nobody else. On a machine
198
+ // where the commits are written with help there is one of these on almost every commit, and
199
+ // left in they are the bulk of what gets read: a signature repeated four hundred times says
200
+ // nothing about what anybody did.
201
+ const TRAILER = /^(co-authored-by|signed-off-by|claude-session|reviewed-by|acked-by|change-id):\s/i
202
+ const GENERATED = /^(🤖\s*)?generated with \[/i
203
+
204
+ /** The part of a commit message a person wrote for another person. */
205
+ export function stripTrailers(body) {
206
+ const kept = []
207
+ for (const line of String(body ?? '').split('\n')) {
208
+ const l = line.trim()
209
+ if (TRAILER.test(l) || GENERATED.test(l)) continue
210
+ // The bare session link that follows the signature, on a line of its own.
211
+ if (/^https:\/\/claude\.ai\/code\/session_\S+$/.test(l)) continue
212
+ kept.push(line)
213
+ }
214
+ return kept.join('\n').trim()
215
+ }
216
+
217
+ /**
218
+ * The email git commits with in this directory, wanted for one thing only: as the argument of
219
+ * `--author`, so that a repository shared with other people still tells you about YOUR day.
220
+ *
221
+ * It must never travel further than that argument — not into an answer, not onto disk. It is a
222
+ * personal detail that is not k0's to keep, and `test/clean.test.mjs` is in the build to catch
223
+ * exactly that kind of leak.
224
+ */
225
+ const authors = new Map()
226
+ export async function authorEmail(dir) {
227
+ if (authors.has(dir)) return authors.get(dir)
228
+ const email = await run(GIT(), ['-C', dir, 'config', 'user.email'], OPTS)
229
+ .then((out) => out.trim())
230
+ .catch(() => '')
231
+ authors.set(dir, email)
232
+ return email
233
+ }
234
+
235
+ /**
236
+ * What you committed inside a window, and whether each one is out.
237
+ *
238
+ * `--branches` rather than `HEAD`, for a reason this repository runs into every day: a
239
+ * worktree is a branch of the same repository, so work done in one is invisible from HEAD
240
+ * alone. `--no-merges` is the other half of it — closing a worktree leaves a merge commit
241
+ * that repeats what its own commits already said, and counting both shows every piece of work
242
+ * twice.
243
+ */
244
+ export async function history(dir, { from, to }) {
245
+ if (!available()) return null
246
+ if (!fs.existsSync(path.join(dir, '.git'))) return null
247
+
248
+ const email = await authorEmail(dir)
249
+ const args = [
250
+ '--no-optional-locks',
251
+ '-C',
252
+ dir,
253
+ 'log',
254
+ '--branches',
255
+ '--no-merges',
256
+ `--max-count=${MAX_LOG}`,
257
+ `--since=${new Date(from).toISOString()}`,
258
+ `--until=${new Date(to).toISOString()}`,
259
+ `--format=${FORMAT}`,
260
+ ]
261
+ if (email) args.push(`--author=${email}`)
262
+
263
+ const [log, list, remote] = await Promise.all([
264
+ run(GIT(), args, LOG_OPTS).catch(() => ''), // a repository without a single commit
265
+ run(
266
+ GIT(),
267
+ ['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, '--branches', '--not', '--remotes'],
268
+ OPTS
269
+ )
270
+ .then((out) => out.split('\n').filter(Boolean))
271
+ .catch(() => []),
272
+ hasRemote(dir),
273
+ ])
274
+
275
+ // With no remote there is nowhere for anything to have gone, so nothing here is "waiting to
276
+ // be pushed": it is simply here. Saying otherwise would turn every local experiment into a
277
+ // list of things you had forgotten to do.
278
+ const unpushed = new Set(remote ? list : [])
279
+ const commits = parseLog(log).map((c) => ({ ...c, online: remote ? !unpushed.has(c.sha) : null }))
280
+ // The total counts the ones from before the window too: a commit made on Tuesday and still
281
+ // sitting here on Friday is exactly the kind of thing you opened this page to be told.
282
+ return { commits, remote, unpushedTotal: remote ? unpushed.size : 0 }
283
+ }
284
+
285
+ /** The files left hanging in the working tree, by path, relative to the repository. */
286
+ export async function dirtyFiles(dir) {
287
+ if (!available()) return []
288
+ if (!fs.existsSync(path.join(dir, '.git'))) return []
289
+ return run(GIT(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '-z'], OPTS)
290
+ .then((out) => parseChanged(out).slice(0, MAX_DIRTY))
291
+ .catch(() => [])
292
+ }
@@ -0,0 +1,31 @@
1
+ // ── Who is allowed to talk to this server ────────────────────────────────────
2
+ // k0 listens on the loopback interface only, which keeps the rest of the network out. What it
3
+ // does not keep out is a web page open in your browser: any site can make requests to
4
+ // 127.0.0.1, and this API opens terminals and starts sessions.
5
+ //
6
+ // Two checks close that door.
7
+ //
8
+ // The `Host` header must be one we recognise. A page cannot forge it, and requiring it stops
9
+ // DNS rebinding — the trick where a hostile domain resolves to 127.0.0.1 so the browser thinks
10
+ // it is talking to its own site.
11
+ //
12
+ // The `Origin` header, when there is one, must be ours. Browsers attach it to every
13
+ // cross-origin request and to every same-origin POST, so a hostile page is always identified.
14
+ // Things that are not browsers — the menu bar icon, curl, the import skill — send none, and
15
+ // they are let through: they are already running as you, on your machine, and a header would
16
+ // not tell us anything a hostile process could not also write.
17
+ //
18
+ // It lives in a file of its own so that it can be read, and tested, without starting a server:
19
+ // this is the whole of the promise made in SECURITY.md, and it is four lines long.
20
+
21
+ /** The names this machine answers to. Anything else is somebody else's idea of where we are. */
22
+ const NAMES = ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]']
23
+
24
+ /** Whether a request may be answered, given its headers and the port we are listening on. */
25
+ export function allowed(headers, port) {
26
+ const host = String(headers.host || '')
27
+ if (!NAMES.some((name) => host === `${name}:${port}`)) return false
28
+ const origin = headers.origin
29
+ if (origin && !NAMES.some((name) => origin === `http://${name}:${port}`)) return false
30
+ return true
31
+ }
package/server/index.js CHANGED
@@ -3,8 +3,9 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import crypto from 'node:crypto'
5
5
  import * as db from './db.js'
6
+ import { allowed } from './guard.js'
6
7
  import { ROOT } from './paths.js'
7
- import { readLiveSessions, deriveStatus, forgetSession, renameSession } from './watcher.js'
8
+ import { readLiveSessions, deriveStatus, forgetSession, renameSession, busy } from './watcher.js'
8
9
  import {
9
10
  launch,
10
11
  focusWindow,
@@ -18,9 +19,12 @@ import * as mode from './mode.js'
18
19
  import { listProjects, onDisk, projectName } from './projects.js'
19
20
  import { scanSessions } from './sessions.js'
20
21
  import * as git from './git.js'
22
+ import * as changelog from './changelog.js'
23
+ import * as writer from './writer.js'
21
24
  import * as files from './files.js'
22
25
  import * as pdf from './pdf.js'
23
26
  import * as machine from './machine.js'
27
+ import * as servers from './servers.js'
24
28
  import { report as platformReport } from '../platform/index.js'
25
29
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
26
30
  // called, and two different readings of the same header would give two different names.
@@ -68,37 +72,6 @@ function tick() {
68
72
  let lastBoard = 0
69
73
  setInterval(tick, 1000)
70
74
 
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
75
  // ── API ──────────────────────────────────────────────────────────────────────
103
76
  const MIME = {
104
77
  '.html': 'text/html; charset=utf-8',
@@ -170,6 +143,9 @@ function board() {
170
143
  load: machine.loadOf(live.get(c.session_id)?.pid),
171
144
  }))
172
145
  const paths = [...new Set(cards.map((c) => c.project_path))]
146
+ // And the same for the dev servers: looking at the board is what makes k0 ask the machine
147
+ // who is listening, and it asks only about the repositories in front of you.
148
+ servers.touch(paths)
173
149
 
174
150
  // Live sessions no card claims: they weigh the same, and if the computer is struggling it is
175
151
  // fair to know there are three of them open outside here.
@@ -180,7 +156,14 @@ function board() {
180
156
  // The columns are the projects with at least one card — and that still exist: a card is
181
157
  // kept alive by its directory (see `onDisk` in projects.js).
182
158
  columns: paths
183
- .map((p) => ({ path: p, name: projectName(p), git: publicGit(git.stateOf(p)) }))
159
+ .map((p) => ({
160
+ path: p,
161
+ name: projectName(p),
162
+ git: publicGit(git.stateOf(p)),
163
+ // Null where the repository has no way to start a server: then there is no globe at
164
+ // all, rather than one that would do nothing.
165
+ server: servers.stateOf(p),
166
+ }))
184
167
  .sort((a, b) => a.name.localeCompare(b.name)),
185
168
  cards,
186
169
  // The repositories for the last column, the one you go through to reach the files of a
@@ -309,6 +292,50 @@ function site(req, res, seg) {
309
292
  // is a much smaller loss than that, so the header is gone.
310
293
  const SANDBOX = 'sandbox'
311
294
 
295
+ // ── The recap of a window ────────────────────────────────────────────────────
296
+ // Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
297
+ // write-up to start, then asks every second whether it is done. Reading every repository's git
298
+ // history once a second to answer "not yet" would be absurd. Nothing is written down, and a
299
+ // minute later the same question is asked of git again from scratch.
300
+ const RECAP_TTL = 60000
301
+ let recap = null
302
+
303
+ async function recapFor(raw, { fresh = true } = {}) {
304
+ const period = changelog.PERIODS.includes(raw) ? raw : 'last'
305
+ // The page asks about the write-up once a second until it is done, and a model can take
306
+ // longer than a minute: those questions must reuse whatever window is already open, or a
307
+ // slow answer would mean a full sweep of every repository's git, every second, while waiting.
308
+ if (recap && recap.period === period && (!fresh || Date.now() - recap.at < RECAP_TTL)) return recap
309
+ const data = await changelog.facts(period)
310
+ recap = {
311
+ period,
312
+ at: Date.now(),
313
+ // The window is part of the name: at midnight "yesterday" becomes a different day, and a
314
+ // summary written for the old one must not be handed over as if it were the new one.
315
+ key: `${data.period}:${data.from}:${data.to}`,
316
+ public: data,
317
+ payload: JSON.stringify(
318
+ {
319
+ period: data.period,
320
+ from: new Date(data.from).toISOString(),
321
+ to: new Date(data.to).toISOString(),
322
+ totals: data.totals,
323
+ repositories: data.repos.map((r) => ({
324
+ name: r.name,
325
+ commits: r.commits.map(({ at, subject, body, online }) => ({ at, subject, body, online })),
326
+ unpushed_total: r.unpushedTotal,
327
+ uncommitted_files: r.dirty.map((f) => f.path),
328
+ unreleased_changelog: r.unreleased,
329
+ cards: r.cards,
330
+ })),
331
+ },
332
+ null,
333
+ 2
334
+ ),
335
+ }
336
+ return recap
337
+ }
338
+
312
339
  async function api(req, res, url) {
313
340
  const seg = url.pathname.split('/').filter(Boolean) // ['api', 'card', '3', 'start']
314
341
  const [, resource, idRaw, action] = seg
@@ -318,6 +345,20 @@ async function api(req, res, url) {
318
345
  if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
319
346
  if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
320
347
 
348
+ // ── What you have been doing ───────────────────────────────────────────────
349
+ // Two halves, deliberately apart. The facts come back straight away and the page can already
350
+ // show them; the write-up takes a model half a minute, so it is started and then asked
351
+ // about, the same once-a-second poll the board and the viewer already live on.
352
+ if (resource === 'changelog' && !idRaw && req.method === 'GET') {
353
+ const data = await recapFor(url.searchParams.get('period'))
354
+ return send(res, 200, { ...data.public, writer: writer.capability() })
355
+ }
356
+ if (resource === 'changelog' && idRaw === 'write') {
357
+ const data = await recapFor(url.searchParams.get('period'), { fresh: false })
358
+ if (req.method === 'POST') return send(res, 200, writer.write(data.key, data.payload))
359
+ if (req.method === 'GET') return send(res, 200, writer.state(data.key))
360
+ }
361
+
321
362
  // ── The four modes ─────────────────────────────────────────────────────────
322
363
  // One control, with two handles: the row of buttons on the board and the four entries in the
323
364
  // menu bar. The text in terminals that are already open changes STRAIGHT AWAY, not only in
@@ -333,6 +374,25 @@ async function api(req, res, url) {
333
374
  return send(res, 200, { mode: mode.current(), lid: mode.lid(), reason: mode.reason() })
334
375
  }
335
376
 
377
+ // ── The dev server of a repository ─────────────────────────────────────────
378
+ // The globe in the column heading. One address for the three gestures, because they are the
379
+ // same gesture with different endings — and `restart` really is stop-then-start, not a third
380
+ // path through the same code.
381
+ //
382
+ // The repository is not taken on trust. It arrives from the page as a plain path and it is a
383
+ // path k0 is about to RUN something in, so it goes through the same allowlist the file viewer
384
+ // uses: a directory k0 already knows as a project, or nothing at all.
385
+ if (resource === 'server' && req.method === 'POST') {
386
+ const b = await readBody(req)
387
+ const repo = rootOf(b.path)
388
+ if (!repo) return send(res, 404, { error: 'k0 does not know that repository.' })
389
+ const action = b.action
390
+ if (action === 'start') return send(res, 200, await servers.start(repo))
391
+ if (action === 'stop') return send(res, 200, await servers.stop(repo))
392
+ if (action === 'restart') return send(res, 200, await servers.restart(repo))
393
+ return send(res, 400, { error: 'That is not something a server can be asked to do.' })
394
+ }
395
+
336
396
  // The screen changed under the windows: a monitor plugged in or unplugged, a different
337
397
  // resolution. This puts them all back in the middle of what is there now, as they were the
338
398
  // day they were born. The menu bar icon calls it, being the one piece of k0 that sees that
@@ -552,6 +612,21 @@ async function api(req, res, url) {
552
612
  return send(res, 200, await focusWindow(card.terminal_window_id))
553
613
  }
554
614
 
615
+ // Closing the terminal without closing the work: the same two moves as Done — stop the
616
+ // session, shut its window — and none of the marking. The card stays where it is, with the
617
+ // status it had, and Resume picks the conversation up where it was.
618
+ if (req.method === 'POST' && action === 'close') {
619
+ if (!card.session_id) return send(res, 400, { error: 'This card has no session' })
620
+ // The board is up to a second behind, so a card that was idle when it was clicked may have
621
+ // started thinking since. Nothing that is working gets stopped from here.
622
+ if (busy(card.status)) return send(res, 409, { error: 'It is working: let it finish' })
623
+ const pid = readLiveSessions().get(card.session_id)?.pid
624
+ const out = await closeTerminal({ winId: card.terminal_window_id, pid })
625
+ db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
626
+ tick() // so the answer already carries the closed card, without waiting for the loop
627
+ return send(res, 200, { ...out, card: db.getCard(id) })
628
+ }
629
+
555
630
  if (req.method === 'POST' && (action === 'start' || action === 'resume')) {
556
631
  const resuming = action === 'resume' && card.session_id
557
632
  const sessionId = resuming ? card.session_id : crypto.randomUUID()
@@ -589,13 +664,23 @@ function serveStatic(res, pathname) {
589
664
  res.writeHead(404).end('Not found')
590
665
  return
591
666
  }
592
- res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' })
667
+ // Nothing here is cached, and that is deliberate. These files have no version in their names
668
+ // and nothing rebuilds them, so with no instruction at all the browser decides for itself how
669
+ // long to keep them — and a tab left open all day keeps the stylesheet it loaded that morning.
670
+ // That turns "change a file, reload the page" into a promise k0 does not keep, and it does the
671
+ // same after an update: the new app on disk, the old interface on screen. They are a few
672
+ // kilobytes over loopback; there is nothing to save here and a whole class of confusion to
673
+ // avoid.
674
+ res.writeHead(200, {
675
+ 'content-type': MIME[path.extname(file)] || 'application/octet-stream',
676
+ 'cache-control': 'no-cache, no-store, must-revalidate',
677
+ })
593
678
  fs.createReadStream(file).pipe(res)
594
679
  }
595
680
 
596
681
  http
597
682
  .createServer(async (req, res) => {
598
- if (!allowed(req)) {
683
+ if (!allowed(req.headers, PORT)) {
599
684
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
600
685
  res.end('k0 only answers to its own address on this machine.')
601
686
  return
@@ -631,6 +716,8 @@ process.on('exit', mode.stopNow)
631
716
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
632
717
  process.on(signal, () => {
633
718
  mode.stopNow()
719
+ // A summary being written when the server goes down has nobody left to give it to.
720
+ writer.stop()
634
721
  process.exit(0)
635
722
  })
636
723
  }
package/server/machine.js CHANGED
@@ -123,6 +123,15 @@ async function cycle() {
123
123
  setInterval(cycle, TTL).unref()
124
124
 
125
125
  // ── What is known right now ───────────────────────────────────────────────────
126
+ /**
127
+ * The process table as it was last sampled, for whoever needs to walk it rather than weigh it —
128
+ * today that is `servers.js`, which follows a dev server down to whichever of its children is
129
+ * actually holding the port. Null until the first cycle has run: the caller decides what a
130
+ * missing sample means, and re-reading the table here to fill the gap would undo the whole
131
+ * point of only sampling while somebody is looking.
132
+ */
133
+ export const tree = () => (snap ? { procs: snap.procs, kids: snap.kids } : null)
134
+
126
135
  /**
127
136
  * What a session weighs: its whole subtree. `top` is the three biggest processes inside it,
128
137
  * and that is the answer to "why is this terminal eating so much".