k0-board 0.1.2 → 0.3.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-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +185 -1
- package/README.md +289 -20
- package/bin/k0-board.js +51 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +69 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/idle.js +56 -0
- package/server/index.js +177 -36
- package/server/launcher.js +5 -1
- package/server/machine.js +9 -0
- package/server/paths.js +5 -4
- package/server/servers.js +481 -0
- package/server/settings.js +121 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +137 -7
- package/web/board.js +285 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
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,
|
|
@@ -15,12 +16,17 @@ import {
|
|
|
15
16
|
relayoutWindows,
|
|
16
17
|
} from './launcher.js'
|
|
17
18
|
import * as mode from './mode.js'
|
|
19
|
+
import * as settings from './settings.js'
|
|
20
|
+
import { dueForClose } from './idle.js'
|
|
18
21
|
import { listProjects, onDisk, projectName } from './projects.js'
|
|
19
22
|
import { scanSessions } from './sessions.js'
|
|
20
23
|
import * as git from './git.js'
|
|
24
|
+
import * as changelog from './changelog.js'
|
|
25
|
+
import * as writer from './writer.js'
|
|
21
26
|
import * as files from './files.js'
|
|
22
27
|
import * as pdf from './pdf.js'
|
|
23
28
|
import * as machine from './machine.js'
|
|
29
|
+
import * as servers from './servers.js'
|
|
24
30
|
import { report as platformReport } from '../platform/index.js'
|
|
25
31
|
// The same front-matter reader the viewer uses: the PDF has to be called what the page is
|
|
26
32
|
// called, and two different readings of the same header would give two different names.
|
|
@@ -61,44 +67,56 @@ function tick() {
|
|
|
61
67
|
// The power levers are checked once a minute rather than every round: reading the power
|
|
62
68
|
// state costs processes, and the things it has to follow — the mains plugged in or out, the
|
|
63
69
|
// battery going down — do not change from one second to the next.
|
|
64
|
-
if (rounds
|
|
70
|
+
if (rounds % 60 === 0) mode.guard()
|
|
71
|
+
// The forgotten terminals, on the same cadence but half a minute off it, so the two jobs that
|
|
72
|
+
// only run once a minute never land in the same second as each other.
|
|
73
|
+
if (rounds % 60 === 30) sweepIdle(live)
|
|
74
|
+
rounds++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Gives back the memory of the windows you have stopped using.
|
|
79
|
+
*
|
|
80
|
+
* It does exactly what the `Close` link on a card does, and on purpose the same three moves and
|
|
81
|
+
* not a path of its own: stop the session, shut the window, forget the window. The card stays
|
|
82
|
+
* where it is with the status it had, `session_id` is untouched, and `Resume` picks the
|
|
83
|
+
* conversation up where it was — a closed terminal is not a lost session.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately not awaited by `tick`: closing a terminal waits up to two seconds for the process
|
|
86
|
+
* to go, and the watching loop must not be held up by it. Hence the latch, the same one
|
|
87
|
+
* `machine.js` and `servers.js` keep for their own sampling — `setInterval` will happily start a
|
|
88
|
+
* second round on top of the first.
|
|
89
|
+
*/
|
|
90
|
+
let sweeping = false
|
|
91
|
+
async function sweepIdle(live) {
|
|
92
|
+
if (sweeping) return
|
|
93
|
+
const hours = settings.read().closeIdleTerminalsAfterHours
|
|
94
|
+
const due = dueForClose({ cards: db.listCards(), live, hours })
|
|
95
|
+
if (!due.length) return
|
|
96
|
+
sweeping = true
|
|
97
|
+
try {
|
|
98
|
+
for (const card of due) {
|
|
99
|
+
try {
|
|
100
|
+
await closeTerminal({ winId: card.terminal_window_id, pid: live.get(card.session_id)?.pid })
|
|
101
|
+
db.setTerminalWindow(card.id, null) // that window is gone; Resume opens a new one
|
|
102
|
+
db.setAutoClosed(card.id, true)
|
|
103
|
+
console.log(`k0 — "${card.title}" sat still for ${hours}h: its terminal is closed, Resume picks it up`)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// One window that refuses to go must not take the others with it, and must certainly not
|
|
106
|
+
// take the server: nothing is awaiting this, so a rejection escaping here would end the
|
|
107
|
+
// process outright. It runs unattended — the next round is the retry.
|
|
108
|
+
console.error(`k0 — could not close the terminal of "${card.title}": ${String(err?.message || err)}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
sweeping = false
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
|
|
67
116
|
/** When somebody last looked at the board. */
|
|
68
117
|
let lastBoard = 0
|
|
69
118
|
setInterval(tick, 1000)
|
|
70
119
|
|
|
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
120
|
// ── API ──────────────────────────────────────────────────────────────────────
|
|
103
121
|
const MIME = {
|
|
104
122
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -170,6 +188,9 @@ function board() {
|
|
|
170
188
|
load: machine.loadOf(live.get(c.session_id)?.pid),
|
|
171
189
|
}))
|
|
172
190
|
const paths = [...new Set(cards.map((c) => c.project_path))]
|
|
191
|
+
// And the same for the dev servers: looking at the board is what makes k0 ask the machine
|
|
192
|
+
// who is listening, and it asks only about the repositories in front of you.
|
|
193
|
+
servers.touch(paths)
|
|
173
194
|
|
|
174
195
|
// Live sessions no card claims: they weigh the same, and if the computer is struggling it is
|
|
175
196
|
// fair to know there are three of them open outside here.
|
|
@@ -180,7 +201,14 @@ function board() {
|
|
|
180
201
|
// The columns are the projects with at least one card — and that still exist: a card is
|
|
181
202
|
// kept alive by its directory (see `onDisk` in projects.js).
|
|
182
203
|
columns: paths
|
|
183
|
-
.map((p) => ({
|
|
204
|
+
.map((p) => ({
|
|
205
|
+
path: p,
|
|
206
|
+
name: projectName(p),
|
|
207
|
+
git: publicGit(git.stateOf(p)),
|
|
208
|
+
// Null where the repository has no way to start a server: then there is no globe at
|
|
209
|
+
// all, rather than one that would do nothing.
|
|
210
|
+
server: servers.stateOf(p),
|
|
211
|
+
}))
|
|
184
212
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
185
213
|
cards,
|
|
186
214
|
// The repositories for the last column, the one you go through to reach the files of a
|
|
@@ -203,6 +231,10 @@ function board() {
|
|
|
203
231
|
mode: mode.current(),
|
|
204
232
|
lid: mode.lid(),
|
|
205
233
|
reason: mode.reason(),
|
|
234
|
+
// After how long a forgotten terminal is closed, so the machine chip can say that it is
|
|
235
|
+
// watching and after how long. Zero means it is switched off, and then the chip says nothing
|
|
236
|
+
// rather than saying "0h" — a number that would read as "closes immediately".
|
|
237
|
+
closeIdleAfterHours: settings.read().closeIdleTerminalsAfterHours,
|
|
206
238
|
// What this machine can and cannot do, so the board greys out the rest and says why
|
|
207
239
|
// instead of offering a button that quietly does nothing.
|
|
208
240
|
platform: platformReport(),
|
|
@@ -309,6 +341,50 @@ function site(req, res, seg) {
|
|
|
309
341
|
// is a much smaller loss than that, so the header is gone.
|
|
310
342
|
const SANDBOX = 'sandbox'
|
|
311
343
|
|
|
344
|
+
// ── The recap of a window ────────────────────────────────────────────────────
|
|
345
|
+
// Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
|
|
346
|
+
// write-up to start, then asks every second whether it is done. Reading every repository's git
|
|
347
|
+
// history once a second to answer "not yet" would be absurd. Nothing is written down, and a
|
|
348
|
+
// minute later the same question is asked of git again from scratch.
|
|
349
|
+
const RECAP_TTL = 60000
|
|
350
|
+
let recap = null
|
|
351
|
+
|
|
352
|
+
async function recapFor(raw, { fresh = true } = {}) {
|
|
353
|
+
const period = changelog.PERIODS.includes(raw) ? raw : 'last'
|
|
354
|
+
// The page asks about the write-up once a second until it is done, and a model can take
|
|
355
|
+
// longer than a minute: those questions must reuse whatever window is already open, or a
|
|
356
|
+
// slow answer would mean a full sweep of every repository's git, every second, while waiting.
|
|
357
|
+
if (recap && recap.period === period && (!fresh || Date.now() - recap.at < RECAP_TTL)) return recap
|
|
358
|
+
const data = await changelog.facts(period)
|
|
359
|
+
recap = {
|
|
360
|
+
period,
|
|
361
|
+
at: Date.now(),
|
|
362
|
+
// The window is part of the name: at midnight "yesterday" becomes a different day, and a
|
|
363
|
+
// summary written for the old one must not be handed over as if it were the new one.
|
|
364
|
+
key: `${data.period}:${data.from}:${data.to}`,
|
|
365
|
+
public: data,
|
|
366
|
+
payload: JSON.stringify(
|
|
367
|
+
{
|
|
368
|
+
period: data.period,
|
|
369
|
+
from: new Date(data.from).toISOString(),
|
|
370
|
+
to: new Date(data.to).toISOString(),
|
|
371
|
+
totals: data.totals,
|
|
372
|
+
repositories: data.repos.map((r) => ({
|
|
373
|
+
name: r.name,
|
|
374
|
+
commits: r.commits.map(({ at, subject, body, online }) => ({ at, subject, body, online })),
|
|
375
|
+
unpushed_total: r.unpushedTotal,
|
|
376
|
+
uncommitted_files: r.dirty.map((f) => f.path),
|
|
377
|
+
unreleased_changelog: r.unreleased,
|
|
378
|
+
cards: r.cards,
|
|
379
|
+
})),
|
|
380
|
+
},
|
|
381
|
+
null,
|
|
382
|
+
2
|
|
383
|
+
),
|
|
384
|
+
}
|
|
385
|
+
return recap
|
|
386
|
+
}
|
|
387
|
+
|
|
312
388
|
async function api(req, res, url) {
|
|
313
389
|
const seg = url.pathname.split('/').filter(Boolean) // ['api', 'card', '3', 'start']
|
|
314
390
|
const [, resource, idRaw, action] = seg
|
|
@@ -318,6 +394,20 @@ async function api(req, res, url) {
|
|
|
318
394
|
if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
|
|
319
395
|
if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
|
|
320
396
|
|
|
397
|
+
// ── What you have been doing ───────────────────────────────────────────────
|
|
398
|
+
// Two halves, deliberately apart. The facts come back straight away and the page can already
|
|
399
|
+
// show them; the write-up takes a model half a minute, so it is started and then asked
|
|
400
|
+
// about, the same once-a-second poll the board and the viewer already live on.
|
|
401
|
+
if (resource === 'changelog' && !idRaw && req.method === 'GET') {
|
|
402
|
+
const data = await recapFor(url.searchParams.get('period'))
|
|
403
|
+
return send(res, 200, { ...data.public, writer: writer.capability() })
|
|
404
|
+
}
|
|
405
|
+
if (resource === 'changelog' && idRaw === 'write') {
|
|
406
|
+
const data = await recapFor(url.searchParams.get('period'), { fresh: false })
|
|
407
|
+
if (req.method === 'POST') return send(res, 200, writer.write(data.key, data.payload))
|
|
408
|
+
if (req.method === 'GET') return send(res, 200, writer.state(data.key))
|
|
409
|
+
}
|
|
410
|
+
|
|
321
411
|
// ── The four modes ─────────────────────────────────────────────────────────
|
|
322
412
|
// One control, with two handles: the row of buttons on the board and the four entries in the
|
|
323
413
|
// menu bar. The text in terminals that are already open changes STRAIGHT AWAY, not only in
|
|
@@ -333,6 +423,25 @@ async function api(req, res, url) {
|
|
|
333
423
|
return send(res, 200, { mode: mode.current(), lid: mode.lid(), reason: mode.reason() })
|
|
334
424
|
}
|
|
335
425
|
|
|
426
|
+
// ── The dev server of a repository ─────────────────────────────────────────
|
|
427
|
+
// The globe in the column heading. One address for the three gestures, because they are the
|
|
428
|
+
// same gesture with different endings — and `restart` really is stop-then-start, not a third
|
|
429
|
+
// path through the same code.
|
|
430
|
+
//
|
|
431
|
+
// The repository is not taken on trust. It arrives from the page as a plain path and it is a
|
|
432
|
+
// path k0 is about to RUN something in, so it goes through the same allowlist the file viewer
|
|
433
|
+
// uses: a directory k0 already knows as a project, or nothing at all.
|
|
434
|
+
if (resource === 'server' && req.method === 'POST') {
|
|
435
|
+
const b = await readBody(req)
|
|
436
|
+
const repo = rootOf(b.path)
|
|
437
|
+
if (!repo) return send(res, 404, { error: 'k0 does not know that repository.' })
|
|
438
|
+
const action = b.action
|
|
439
|
+
if (action === 'start') return send(res, 200, await servers.start(repo))
|
|
440
|
+
if (action === 'stop') return send(res, 200, await servers.stop(repo))
|
|
441
|
+
if (action === 'restart') return send(res, 200, await servers.restart(repo))
|
|
442
|
+
return send(res, 400, { error: 'That is not something a server can be asked to do.' })
|
|
443
|
+
}
|
|
444
|
+
|
|
336
445
|
// The screen changed under the windows: a monitor plugged in or unplugged, a different
|
|
337
446
|
// resolution. This puts them all back in the middle of what is there now, as they were the
|
|
338
447
|
// day they were born. The menu bar icon calls it, being the one piece of k0 that sees that
|
|
@@ -552,6 +661,22 @@ async function api(req, res, url) {
|
|
|
552
661
|
return send(res, 200, await focusWindow(card.terminal_window_id))
|
|
553
662
|
}
|
|
554
663
|
|
|
664
|
+
// Closing the terminal without closing the work: the same two moves as Done — stop the
|
|
665
|
+
// session, shut its window — and none of the marking. The card stays where it is, with the
|
|
666
|
+
// status it had, and Resume picks the conversation up where it was.
|
|
667
|
+
if (req.method === 'POST' && action === 'close') {
|
|
668
|
+
if (!card.session_id) return send(res, 400, { error: 'This card has no session' })
|
|
669
|
+
// The board is up to a second behind, so a card that was idle when it was clicked may have
|
|
670
|
+
// started thinking since. Nothing that is working gets stopped from here.
|
|
671
|
+
if (busy(card.status)) return send(res, 409, { error: 'It is working: let it finish' })
|
|
672
|
+
const pid = readLiveSessions().get(card.session_id)?.pid
|
|
673
|
+
const out = await closeTerminal({ winId: card.terminal_window_id, pid })
|
|
674
|
+
db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
|
|
675
|
+
db.setAutoClosed(id, false) // this one was you: the card must not say otherwise
|
|
676
|
+
tick() // so the answer already carries the closed card, without waiting for the loop
|
|
677
|
+
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
678
|
+
}
|
|
679
|
+
|
|
555
680
|
if (req.method === 'POST' && (action === 'start' || action === 'resume')) {
|
|
556
681
|
const resuming = action === 'resume' && card.session_id
|
|
557
682
|
const sessionId = resuming ? card.session_id : crypto.randomUUID()
|
|
@@ -567,6 +692,7 @@ async function api(req, res, url) {
|
|
|
567
692
|
try {
|
|
568
693
|
const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
|
|
569
694
|
db.setTerminalWindow(id, out.winId)
|
|
695
|
+
db.setAutoClosed(id, false) // there is a window again: whoever shut the last one is history
|
|
570
696
|
tick()
|
|
571
697
|
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
572
698
|
} catch (err) {
|
|
@@ -589,13 +715,23 @@ function serveStatic(res, pathname) {
|
|
|
589
715
|
res.writeHead(404).end('Not found')
|
|
590
716
|
return
|
|
591
717
|
}
|
|
592
|
-
|
|
718
|
+
// Nothing here is cached, and that is deliberate. These files have no version in their names
|
|
719
|
+
// and nothing rebuilds them, so with no instruction at all the browser decides for itself how
|
|
720
|
+
// long to keep them — and a tab left open all day keeps the stylesheet it loaded that morning.
|
|
721
|
+
// That turns "change a file, reload the page" into a promise k0 does not keep, and it does the
|
|
722
|
+
// same after an update: the new app on disk, the old interface on screen. They are a few
|
|
723
|
+
// kilobytes over loopback; there is nothing to save here and a whole class of confusion to
|
|
724
|
+
// avoid.
|
|
725
|
+
res.writeHead(200, {
|
|
726
|
+
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
|
|
727
|
+
'cache-control': 'no-cache, no-store, must-revalidate',
|
|
728
|
+
})
|
|
593
729
|
fs.createReadStream(file).pipe(res)
|
|
594
730
|
}
|
|
595
731
|
|
|
596
732
|
http
|
|
597
733
|
.createServer(async (req, res) => {
|
|
598
|
-
if (!allowed(req)) {
|
|
734
|
+
if (!allowed(req.headers, PORT)) {
|
|
599
735
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
600
736
|
res.end('k0 only answers to its own address on this machine.')
|
|
601
737
|
return
|
|
@@ -609,6 +745,9 @@ http
|
|
|
609
745
|
}
|
|
610
746
|
})
|
|
611
747
|
.listen(PORT, '127.0.0.1', () => {
|
|
748
|
+
// The settings file, written out with everything in it the first time k0 runs: it is the only
|
|
749
|
+
// list of what can be changed, so it has to exist before anybody goes looking for it.
|
|
750
|
+
settings.ensure()
|
|
612
751
|
tick()
|
|
613
752
|
// The mode is remembered, and at startup it puts the machine back as it was: the sleep
|
|
614
753
|
// levers and, where needed, the font size of terminals left open. The server restarts often
|
|
@@ -631,6 +770,8 @@ process.on('exit', mode.stopNow)
|
|
|
631
770
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
632
771
|
process.on(signal, () => {
|
|
633
772
|
mode.stopNow()
|
|
773
|
+
// A summary being written when the server goes down has nobody left to give it to.
|
|
774
|
+
writer.stop()
|
|
634
775
|
process.exit(0)
|
|
635
776
|
})
|
|
636
777
|
}
|
package/server/launcher.js
CHANGED
|
@@ -105,7 +105,11 @@ export async function launch({ card, sessionId, mode = 'start' }) {
|
|
|
105
105
|
const up = await waitForSession(sessionId)
|
|
106
106
|
|
|
107
107
|
if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
|
|
108
|
-
|
|
108
|
+
// The prompt belongs to the beginning. Resuming picks up a conversation that answered it hours
|
|
109
|
+
// ago, and putting it back under the cursor is at best a stale instruction in the way — at
|
|
110
|
+
// worst it is sent, because where pasting is unavailable the fallback below types it. This
|
|
111
|
+
// matters more now that k0 closes forgotten terminals by itself: a Resume is no longer rare.
|
|
112
|
+
if (resume || !up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
|
|
109
113
|
|
|
110
114
|
await waitForPrompt(winId)
|
|
111
115
|
|
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".
|
package/server/paths.js
CHANGED
|
@@ -12,10 +12,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
* there is on borrowed time. Data now lives in a directory owned by the user, and the
|
|
13
13
|
* installed copy of the app is disposable.
|
|
14
14
|
*
|
|
15
|
-
* ~/.k0/k0.db
|
|
16
|
-
* ~/.k0/
|
|
17
|
-
* ~/.k0/
|
|
18
|
-
* ~/.k0/
|
|
15
|
+
* ~/.k0/k0.db the board
|
|
16
|
+
* ~/.k0/config.json the settings, written out with everything in it on the first run
|
|
17
|
+
* ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
|
|
18
|
+
* ~/.k0/logs/ service stdout/stderr
|
|
19
|
+
* ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
|
|
19
20
|
*
|
|
20
21
|
* On Windows the same tree goes under %LOCALAPPDATA%\k0, which is the local-only,
|
|
21
22
|
* not-roamed location Windows expects for this kind of state.
|