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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +163 -1
- package/README.md +223 -20
- package/bin/k0-board.js +37 -3
- package/menubar/K0MenuBar.swift +105 -22
- package/menubar/build.sh +7 -1
- 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/linux/tray.py +42 -2
- 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/platform/win32/tray.ps1 +11 -0
- package/server/changelog.js +242 -0
- package/server/db.js +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +162 -12
- package/web/board.js +278 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +9 -7
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
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
|
+
}
|
package/server/guard.js
ADDED
|
@@ -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) => ({
|
|
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
|
-
|
|
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".
|