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.
@@ -0,0 +1,481 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { spawn } from 'node:child_process'
5
+ import { servers as os, capabilities } from '../platform/index.js'
6
+ import { alive } from '../platform/shared/run.js'
7
+ import { LOG_DIR, ensureDirs } from './paths.js'
8
+ import * as db from './db.js'
9
+ import { tree, subtree } from './machine.js'
10
+
11
+ export { parseListeners, parseCwds, parseSs, parseNetTcp, portOf } from '../platform/shared/listeners.js'
12
+
13
+ // ── The dev server a project runs while you work on it ────────────────────────
14
+ // The globe in a column heading. It answers one question — is this repository's server up, and
15
+ // on what — and takes one instruction: turn it on, turn it off, turn it over.
16
+ //
17
+ // Three things make it different from everything else k0 starts.
18
+ //
19
+ // It OUTLIVES THE SESSION. The old way round was a skill in each repository that ran `npm run
20
+ // dev` as a background task inside a Claude Code session, and a background task belongs to its
21
+ // session: close the session and the server goes with it. Here the process is detached, owned by
22
+ // nobody, and it survives k0 restarting as readily as it survives a session closing. That is the
23
+ // whole point of the feature, and it is why this module is deliberately absent from the shutdown
24
+ // handlers in index.js.
25
+ //
26
+ // It is NEVER ASKED whether it is up. k0 makes no network requests — the rule is in CLAUDE.md and
27
+ // it is not negotiable — so "up" is not a 200 to a request nobody should be making. It is: the
28
+ // process is alive AND the operating system says it is holding a TCP port open. That is stronger
29
+ // evidence anyway. A server that has bound its port but is still compiling answers a request with
30
+ // an error and this test with the truth.
31
+ //
32
+ // And it is HONEST ABOUT WHAT IT DID NOT START. A server somebody started by hand in a terminal
33
+ // is recognised, shown green and governed like the rest, because a globe that only knew about its
34
+ // own servers would be grey next to a site that is plainly running. Where a platform cannot tell
35
+ // whose repository an outside process belongs to — Windows — the interface says so rather than
36
+ // drawing it as off.
37
+
38
+ /** How long a server gets to open a port before k0 stops calling it "starting". */
39
+ const GRACE = 90_000
40
+
41
+ /** Ports the kernel hands out for outgoing connections: never what a dev server was asked for. */
42
+ const EPHEMERAL = 49152
43
+
44
+ /** How long a reading of the machine's sockets stays good for. The globe is not a stopwatch. */
45
+ const TTL = 4000
46
+
47
+ /** How long a glance at the board counts for before the sampling goes quiet again. */
48
+ const ATTENTION = 15000
49
+
50
+ /** How far up a process's ancestry to look for the top of a dev server. */
51
+ const MAX_DEPTH = 12
52
+
53
+ // ── Deciding whether a repository has a server at all ─────────────────────────
54
+
55
+ /**
56
+ * Which script starts this project, out of its package.json.
57
+ *
58
+ * `start` is deliberately not in the list. By convention it runs what has been BUILT, which is
59
+ * not what a globe on a working board means — and there is a concrete trap behind the
60
+ * convention: k0's own package.json has only `start`, so accepting it would put a globe on k0
61
+ * that tried to launch a second k0 on a port the first one is already holding.
62
+ *
63
+ * The command is always `npm run <script>` and never the script's own text. Scripts chain
64
+ * (`velite && node scripts/build-posts-index.mjs && vite`) and rely on node_modules/.bin being
65
+ * on PATH, which is something only `npm run` arranges.
66
+ */
67
+ export function detectCommand(pkg) {
68
+ const scripts = pkg && typeof pkg === 'object' ? pkg.scripts : null
69
+ if (!scripts || typeof scripts !== 'object') return null
70
+ for (const script of ['dev', 'serve']) {
71
+ if (typeof scripts[script] === 'string' && scripts[script].trim()) {
72
+ return { script, command: `npm run ${script}` }
73
+ }
74
+ }
75
+ return null
76
+ }
77
+
78
+ // Reading a package.json is cheap, but board() asks about every repository every second and
79
+ // there is no reason to pay for it that often. A repository that grows a `dev` script waits at
80
+ // most this long for its globe.
81
+ const DETECT_TTL = 30_000
82
+ const detected = new Map() // path -> { at, command }
83
+
84
+ export function commandFor(repo) {
85
+ const hit = detected.get(repo)
86
+ if (hit && Date.now() - hit.at < DETECT_TTL) return hit.command
87
+ let command = null
88
+ try {
89
+ command = detectCommand(JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')))
90
+ } catch {
91
+ // No package.json, or one that is being written to right now. Either way: no globe, and
92
+ // the next glance will ask again.
93
+ }
94
+ detected.set(repo, { at: Date.now(), command })
95
+ return command
96
+ }
97
+
98
+ // ── The pure decisions ───────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Which port to show, out of everything a server's processes hold open. The lowest wins: a
102
+ * framework that opens a second socket opens it above the one it was asked for. Ports the
103
+ * kernel handed out for outgoing connections are not offers and are dropped.
104
+ */
105
+ export function pickPort(ports) {
106
+ const usable = [...(ports ?? [])].filter((p) => p > 0 && p < EPHEMERAL).sort((a, b) => a - b)
107
+ return usable[0] ?? null
108
+ }
109
+
110
+ export const urlFor = (port) => (port ? `http://localhost:${port}` : null)
111
+
112
+ /**
113
+ * The four states, from facts only.
114
+ *
115
+ * off nothing is running, and nothing tried
116
+ * starting a process is alive but has not opened a port yet, and is still within its grace
117
+ * up a process is alive and holding a port
118
+ * failed it was started and it is not up: either the process is gone, or the grace ran out
119
+ *
120
+ * The row in the database is what separates "we stopped it" from "it died": stopping deletes
121
+ * the row, so a row whose process is gone is a server that fell over.
122
+ */
123
+ export function phaseOf({ started, running, port, startedAt, now = Date.now(), sampled = true }) {
124
+ if (port) return 'up'
125
+ if (running) {
126
+ // Nothing has been read off the machine yet — the first glance after k0 itself restarted,
127
+ // say, with a dev server that has been up for hours. "We do not know" wears the same face as
128
+ // "coming up", because a red globe on no evidence at all is an accusation, not a report.
129
+ if (!sampled) return 'starting'
130
+ return now - (startedAt ?? now) > GRACE ? 'failed' : 'starting'
131
+ }
132
+ // Nothing is running. Whether that is a quiet globe or a red one turns entirely on `started`:
133
+ // k0 was told to start this and there is no process, so it fell over — and saying so is the
134
+ // difference between a globe that reports and one that shrugs. A server nobody asked for is
135
+ // simply off.
136
+ return started ? 'failed' : 'off'
137
+ }
138
+
139
+ /** Where a repository's output goes. Two repositories never share a file, whatever they are called. */
140
+ export function logPathFor(repo) {
141
+ const short = crypto.createHash('sha1').update(String(repo)).digest('hex').slice(0, 8)
142
+ const name = path.basename(String(repo)).replace(/[^a-zA-Z0-9._-]/g, '-') || 'repo'
143
+ return path.join(LOG_DIR, `dev-${name}-${short}.log`)
144
+ }
145
+
146
+ /**
147
+ * Is `dir` the repository, or inside it?
148
+ *
149
+ * Compared a whole segment at a time, so `/a/bc` is not inside `/a/b` — without that, a
150
+ * neighbouring directory whose name merely starts the same would be adopted as if it were this
151
+ * repository, and the globe would then govern somebody else's server.
152
+ *
153
+ * Both separators count, rather than whichever one this machine happens to use. The paths on
154
+ * either side come from different places — one from the operating system, one from a card or from
155
+ * Claude Code's own files — and on Windows those two do not reliably agree on which slash they
156
+ * write. Asking `path.sep` would make the answer depend on the machine reading it, which is how a
157
+ * comparison starts being right in the tests and wrong in the field.
158
+ */
159
+ export function isInside(dir, repo) {
160
+ if (!dir || !repo) return false
161
+ if (dir === repo) return true
162
+ const base = String(repo).replace(/[\\/]+$/, '')
163
+ return dir.startsWith(base) && (dir[base.length] === '/' || dir[base.length] === '\\')
164
+ }
165
+
166
+ /** The last thing the log has to say, which is what a failure owes the person looking at it. */
167
+ export function lastLine(text, limit = 200) {
168
+ const lines = String(text ?? '')
169
+ .split('\n')
170
+ .map((l) => l.replace(/\[[0-9;]*m/g, '').trim()) // colour codes say nothing in a tooltip
171
+ .filter(Boolean)
172
+ const line = lines[lines.length - 1] ?? ''
173
+ return line.length > limit ? `${line.slice(0, limit - 1)}…` : line
174
+ }
175
+
176
+ /** The tail of a log file without reading the whole thing: a dev server writes a lot. */
177
+ export function logTail(file, bytes = 4096) {
178
+ try {
179
+ const { size } = fs.statSync(file)
180
+ const from = Math.max(0, size - bytes)
181
+ const fd = fs.openSync(file, 'r')
182
+ try {
183
+ const buf = Buffer.alloc(Math.min(bytes, size - from))
184
+ fs.readSync(fd, buf, 0, buf.length, from)
185
+ return buf.toString('utf8')
186
+ } finally {
187
+ fs.closeSync(fd)
188
+ }
189
+ } catch {
190
+ return ''
191
+ }
192
+ }
193
+
194
+ // ── Reading the machine ──────────────────────────────────────────────────────
195
+ // The same discipline machine.js already lives by, and for the same reason: asking the whole
196
+ // system who is listening costs real time on a busy laptop, and a board nobody is looking at
197
+ // has nothing to ask for.
198
+
199
+ let watching = 0
200
+ let watched = [] // the repositories currently on the board
201
+ let snap = null // { at, byRepo: Map<path, {root, pids, port}> }
202
+ // The reading in flight, if there is one. A second asker WAITS for it rather than skipping: the
203
+ // obvious version of this — a boolean, and return early if it is set — would let `stop` sail past
204
+ // a reading that had not finished and go on to signal whatever the last one happened to say.
205
+ let sampling = null
206
+
207
+ /** Somebody is looking at the board, and at these repositories. */
208
+ export function touch(repos = []) {
209
+ watching = Date.now()
210
+ watched = repos
211
+ if (!snap) cycle()
212
+ }
213
+
214
+ /**
215
+ * Every process between `pid` and the top, itself included. It stops at k0's own process for
216
+ * the same reason it stops at init: whatever is above there is not part of a dev server.
217
+ */
218
+ export function ancestry(procs, pid, self = process.pid) {
219
+ const chain = []
220
+ let cur = pid
221
+ for (let i = 0; i < MAX_DEPTH && cur > 1 && cur !== self; i++) {
222
+ chain.push(cur)
223
+ const parent = procs.get(cur)?.ppid
224
+ if (!parent || parent === cur) break
225
+ cur = parent
226
+ }
227
+ return chain
228
+ }
229
+
230
+ /** Which pids to ask the operating system about, when looking for servers k0 did not start. */
231
+ export function strayPids(ports, procs, kids, self = process.pid) {
232
+ const mine = procs.size ? subtree(kids, self) : new Set([self])
233
+ return [...ports.keys()].filter((pid) => !mine.has(pid))
234
+ }
235
+
236
+ /**
237
+ * Which server belongs to which repository — the whole of the deciding, with none of the asking.
238
+ *
239
+ * It is separated from `read` below for the reason every parser in k0 is separated from the
240
+ * command that feeds it: this is the part that can be wrong quietly. Everything it needs arrives
241
+ * as a value — who is listening on what, who descends from whom, who is working from where, and
242
+ * what k0 remembers starting — and what comes back is the answer, so it can be read and proved
243
+ * without a single process being spawned.
244
+ *
245
+ * @param {object} facts
246
+ * @param {Map<number, Set<number>>} facts.ports pid -> the ports it holds open
247
+ * @param {Map<number, {ppid: number, cmd: string}>} facts.procs
248
+ * @param {Map<number, number[]>} facts.kids who descends from whom
249
+ * @param {Map<number, string>} facts.cwds pid -> where it is working from
250
+ * @param {{project_path: string, pid: number, command: string}[]} facts.rows what k0 started
251
+ * @param {string[]} facts.repos the repositories on the board right now
252
+ * @param {(pid: number) => boolean} facts.isAlive
253
+ * @param {boolean} facts.canAdopt
254
+ */
255
+ export function attribute({ ports, procs, kids, cwds, rows, repos, isAlive = alive, canAdopt = true, self = process.pid }) {
256
+ const byRepo = new Map()
257
+
258
+ // Servers k0 started: the root is known, so the only question is which of its children got the
259
+ // port.
260
+ //
261
+ // Except that a remembered pid is only a number, and the operating system hands numbers out
262
+ // again. A row written before a reboot can point at somebody else's process entirely — and this
263
+ // is the map that `stop` later kills by, so guessing here would be a way to kill the wrong
264
+ // thing. Where the process table can be read, the command it was started with has to still be
265
+ // the command it is running.
266
+ for (const row of rows) {
267
+ if (!isAlive(row.pid)) continue
268
+ if (procs.size && !(procs.get(row.pid)?.cmd ?? '').includes(row.command)) continue
269
+ const pids = procs.has(row.pid) ? [...subtree(kids, row.pid)] : [row.pid]
270
+ const held = new Set()
271
+ for (const pid of pids) for (const port of ports.get(pid) ?? []) held.add(port)
272
+ byRepo.set(row.project_path, { root: row.pid, pids, port: pickPort(held), adopted: false })
273
+ }
274
+
275
+ if (!canAdopt) return byRepo
276
+
277
+ // And the ones somebody started by hand. The listening process's own working directory is what
278
+ // ties it to a repository — `vite` and `next` both inherit it from the `npm run` that started
279
+ // them — and the top of the server is the highest ancestor still working from inside that same
280
+ // repository.
281
+ const strays = strayPids(ports, procs, kids, self)
282
+ for (const repo of repos.filter((r) => !byRepo.has(r))) {
283
+ let best = null
284
+ for (const pid of strays) {
285
+ if (!isInside(cwds.get(pid), repo)) continue
286
+ const port = pickPort(ports.get(pid))
287
+ if (port === null) continue
288
+ if (best && best.port <= port) continue
289
+ const chain = ancestry(procs, pid, self)
290
+ // The highest ancestor still inside the repository is the `npm run` that was typed, and
291
+ // stopping that is what stops the whole thing.
292
+ const root = [...chain].reverse().find((p) => isInside(cwds.get(p), repo)) ?? pid
293
+ best = { root, pids: procs.size ? [...subtree(kids, root)] : chain, port, adopted: true }
294
+ }
295
+ if (best) byRepo.set(repo, best)
296
+ }
297
+ return byRepo
298
+ }
299
+
300
+ /** The reading itself, with no regard for whether anybody asked. `cycle` is the polite one. */
301
+ function sample() {
302
+ if (!sampling) {
303
+ sampling = read().finally(() => {
304
+ sampling = null
305
+ })
306
+ }
307
+ return sampling
308
+ }
309
+
310
+ /** The asking, which is all this does. The deciding is `attribute`, above. */
311
+ async function read() {
312
+ try {
313
+ const ports = capabilities.servers.ports ? await os.listeners() : new Map()
314
+ const t = tree()
315
+ const procs = t?.procs ?? new Map()
316
+ const kids = t?.kids ?? new Map()
317
+ const rows = db.listDevServers()
318
+ const canAdopt = capabilities.servers.adopt
319
+
320
+ // Where a process is working from is a second question to the operating system, and it is
321
+ // only worth asking about the processes that could still turn out to be somebody's dev
322
+ // server: the ones listening that k0 did not start itself, and everything above them.
323
+ let cwds = new Map()
324
+ if (canAdopt) {
325
+ const strays = strayPids(ports, procs, kids)
326
+ const claimed = new Set(rows.map((r) => r.project_path))
327
+ if (strays.length && watched.some((repo) => !claimed.has(repo))) {
328
+ cwds = await os.cwds([...new Set(strays.flatMap((pid) => ancestry(procs, pid)))])
329
+ }
330
+ }
331
+
332
+ snap = { at: Date.now(), byRepo: attribute({ ports, procs, kids, cwds, rows, repos: watched, canAdopt }) }
333
+ } catch {
334
+ /* one bad reading does not throw away what was already known */
335
+ }
336
+ }
337
+
338
+ /** A reading, but only while somebody is looking. See machine.js, which lives by the same rule. */
339
+ async function cycle() {
340
+ if (Date.now() - watching > ATTENTION) return
341
+ await sample()
342
+ }
343
+
344
+ setInterval(cycle, TTL).unref()
345
+
346
+ // ── What the board is told ───────────────────────────────────────────────────
347
+
348
+ /**
349
+ * Everything the globe needs for one repository, or null when this repository has no way to
350
+ * start a server and should not have a globe at all.
351
+ */
352
+ export function stateOf(repo) {
353
+ const command = commandFor(repo)
354
+ if (!command) return null
355
+
356
+ const found = snap?.byRepo.get(repo) ?? null
357
+ const row = db.getDevServer(repo)
358
+ // A row whose process is gone is a server that fell over: stopping it would have removed the
359
+ // row. Without a row and without a sighting there is simply nothing running.
360
+ const up = !!found || (!!row && alive(row.pid))
361
+ const port = found?.port ?? null
362
+ const phase = phaseOf({ started: !!row, running: up, port, startedAt: row?.started_at, sampled: !!snap })
363
+
364
+ const state = {
365
+ state: phase,
366
+ port,
367
+ url: urlFor(port),
368
+ command: row?.command ?? command.command,
369
+ script: command.script,
370
+ adopted: !!found?.adopted,
371
+ pid: found?.root ?? row?.pid ?? null,
372
+ log: logPathFor(repo),
373
+ error: null,
374
+ // What this machine can do about it, so the interface greys out rather than pretends.
375
+ can: { run: capabilities.servers.run, ports: capabilities.servers.ports, adopt: capabilities.servers.adopt },
376
+ }
377
+ if (phase === 'failed') state.error = lastLine(logTail(state.log))
378
+ return state
379
+ }
380
+
381
+ // ── Turning it on and off ────────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Start the project's dev server, detached.
385
+ *
386
+ * `detached` is what gives the process a group of its own, which is both how it survives k0 and
387
+ * how stopping it can take its children with it. `unref` is what lets k0 exit while it runs. The
388
+ * output goes to a file rather than to a pipe nobody is reading: a pipe with a full buffer and
389
+ * no reader stops the writer, which would hang the server the moment it got chatty.
390
+ */
391
+ export async function start(repo) {
392
+ if (!capabilities.servers.run) throw new Error('k0 cannot start dev servers on this machine.')
393
+ const command = commandFor(repo)
394
+ if (!command) throw new Error('This repository has no `dev` or `serve` script to run.')
395
+ if (!fs.existsSync(repo)) throw new Error('That directory is not there any more.')
396
+
397
+ const existing = stateOf(repo)
398
+ if (existing && (existing.state === 'up' || existing.state === 'starting')) return existing
399
+
400
+ ensureDirs()
401
+ const log = logPathFor(repo)
402
+ // Truncated, not appended: what matters is why THIS run failed, not the ten before it.
403
+ const fd = fs.openSync(log, 'w')
404
+ try {
405
+ fs.writeSync(fd, `$ ${command.command}\n in ${repo}\n at ${new Date().toISOString()}\n\n`)
406
+ const { file, args } = os.shell(command.command)
407
+ const child = spawn(file, args, {
408
+ cwd: repo,
409
+ detached: true,
410
+ stdio: ['ignore', fd, fd],
411
+ windowsHide: true,
412
+ })
413
+ child.unref()
414
+ // A spawn that fails does so asynchronously, and there is no one left to hear it: the log
415
+ // is where the reason goes, and `stateOf` reads it back out when the globe turns red.
416
+ child.on('error', (err) => {
417
+ try {
418
+ fs.appendFileSync(log, `\nk0 could not start it: ${err.message}\n`)
419
+ } catch {
420
+ /* the log is a courtesy, not a contract */
421
+ }
422
+ })
423
+ db.setDevServer(repo, { pid: child.pid, command: command.command, startedAt: Date.now() })
424
+ } finally {
425
+ fs.closeSync(fd)
426
+ }
427
+ // The next reading will find the port; until then the globe spins, which is the truth.
428
+ snap = null
429
+ touch(watched.includes(repo) ? watched : [...watched, repo])
430
+ return stateOf(repo)
431
+ }
432
+
433
+ /**
434
+ * Stop it, and everything it started. SIGTERM first — a dev server given the chance will clean
435
+ * up after itself — then SIGKILL for whatever is still standing, on the same three-second
436
+ * patience `terminal.close` already uses.
437
+ */
438
+ export async function stop(repo) {
439
+ // Read the machine FIRST, and kill only what that reading found. The stored pid on its own is
440
+ // not enough to act on: it is a number the operating system is free to hand out again, and
441
+ // acting on a stale one would mean killing whatever now happens to wear it. The sample checks
442
+ // that the process is still running the command it was started with — see the guard in
443
+ // `sample` — so by the time anything is signalled here, it has been identified twice.
444
+ watching = Date.now()
445
+ if (!watched.includes(repo)) watched = [...watched, repo]
446
+ await sample()
447
+ const found = snap?.byRepo.get(repo) ?? null
448
+
449
+ // The row goes unconditionally: it is what says "k0 started this and it is meant to be
450
+ // running", and leaving it behind would turn a stop into a failure the next time anybody
451
+ // looked at the globe.
452
+ db.clearDevServer(repo)
453
+ if (!found) {
454
+ snap = null
455
+ return stateOf(repo)
456
+ }
457
+
458
+ await os.stop(found.root, found.pids, 'SIGTERM')
459
+ // The same three seconds of patience `terminal.close` gives a session: long enough for a dev
460
+ // server to put its own things away, short enough that nobody thinks the click was missed.
461
+ const deadline = Date.now() + 3000
462
+ while (Date.now() < deadline && alive(found.root)) await new Promise((r) => setTimeout(r, 150))
463
+ if (alive(found.root)) await os.stop(found.root, found.pids, 'SIGKILL')
464
+ snap = null
465
+ await sample()
466
+ return stateOf(repo)
467
+ }
468
+
469
+ /** Off, then on. There is no third way to restart a process that does not know it can be. */
470
+ export async function restart(repo) {
471
+ await stop(repo)
472
+ return start(repo)
473
+ }
474
+
475
+ /** For the tests, which must not inherit a previous file's sampling. */
476
+ export function forget() {
477
+ snap = null
478
+ watching = 0
479
+ watched = []
480
+ detected.clear()
481
+ }
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { HOME } from './paths.js'
4
+
5
+ /**
6
+ * The few things about k0 you are allowed to change, and the file they live in.
7
+ *
8
+ * k0 has never had one of these, and this is deliberately not the first room of a settings
9
+ * panel. The board shows what changes during a working day; a number you set once and then never
10
+ * look at again is the opposite of that, and putting it on screen would cost the one page that
11
+ * has to stay readable in exchange for nothing.
12
+ *
13
+ * So it is a file, and the file IS the list: k0 writes it out with every setting already in it at
14
+ * its default, so that opening it is how you find out what there is to change. `k0-board doctor`
15
+ * prints the same thing, for anyone who would rather ask than look.
16
+ *
17
+ * It lives beside the database and not inside the app, because the app directory is disposable —
18
+ * `npx` unpacks into a cache npm may wipe, and `k0-board install` overwrites it — and a setting
19
+ * an update throws away is not a setting. And it is read again whenever it changes, so editing
20
+ * it takes effect on the next round rather than after a restart.
21
+ */
22
+
23
+ /** `K0_CONFIG` is how the tests stay away from the real file, the way `K0_DB` does for the board. */
24
+ export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
25
+
26
+ /**
27
+ * Every setting there is, and what it is worth if you never touch the file. No default is written
28
+ * down anywhere else: this object is the whole answer to "and if I leave it alone?".
29
+ */
30
+ export const DEFAULTS = {
31
+ closeIdleTerminalsAfterHours: 12,
32
+ }
33
+
34
+ /**
35
+ * The shortest timeout that is still a timeout. The sweep goes round once a minute, and a
36
+ * terminal you were typing into ten minutes ago is not one you have forgotten: below an hour this
37
+ * stops being a tidy-up and becomes a machine closing windows while you are using them. A number
38
+ * under it is raised rather than refused — the intent was clearly "be aggressive about it", and an
39
+ * hour is as aggressive as this is willing to be.
40
+ */
41
+ export const MIN_HOURS = 1
42
+
43
+ /** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
44
+ const NOTE =
45
+ 'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
46
+ 'Delete a line to go back to its default. Hours at 0 switch that closing off entirely. ' +
47
+ 'See Settings in the README.'
48
+
49
+ /**
50
+ * How long a terminal may sit there. Anything that is not a number falls back to the default
51
+ * rather than blowing up: this arrives from a file somebody edits by hand, and from there
52
+ * anything can arrive. Zero and below mean off, which is how the whole thing is switched off
53
+ * without a second switch to be kept in step with the first.
54
+ */
55
+ export function idleHours(raw) {
56
+ if (raw === undefined || raw === null || raw === '') return DEFAULTS.closeIdleTerminalsAfterHours
57
+ const n = Number(raw)
58
+ if (!Number.isFinite(n)) return DEFAULTS.closeIdleTerminalsAfterHours
59
+ if (n <= 0) return 0
60
+ return Math.max(MIN_HOURS, n)
61
+ }
62
+
63
+ /** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
64
+ export function normalise(raw) {
65
+ const o = raw && typeof raw === 'object' ? raw : {}
66
+ return { closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours) }
67
+ }
68
+
69
+ let cached = null
70
+ let stamp = null // the mtime the cache was built from, or null for "there was no file"
71
+ let broken = false
72
+
73
+ /**
74
+ * The settings in force. The file is only re-read when its mtime moves, so this can be called
75
+ * from the watching loop without costing a read a second, and changing the file still lands
76
+ * without a restart.
77
+ */
78
+ export function read() {
79
+ let mtime = null
80
+ try {
81
+ mtime = fs.statSync(PATH).mtimeMs
82
+ } catch {
83
+ /* no file: the defaults hold, and there is nothing to remember */
84
+ }
85
+ if (cached && stamp === mtime) return cached
86
+ stamp = mtime
87
+ if (mtime === null) broken = false // a file that is not there is not a file that is broken
88
+ cached = normalise(mtime === null ? null : parse())
89
+ return cached
90
+ }
91
+
92
+ function parse() {
93
+ try {
94
+ broken = false
95
+ return JSON.parse(fs.readFileSync(PATH, 'utf8'))
96
+ } catch {
97
+ // Half-saved by an editor, or one comma too many. k0 goes on with the defaults rather than
98
+ // refusing to start, and `doctor` is where you find out the file is being ignored — silence
99
+ // would be the worst of both: a setting you believe is in force and is not.
100
+ broken = true
101
+ return null
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Writes the file out, with everything in it, if it is not there yet. Called explicitly at
107
+ * startup rather than from an import: a module that writes to your disk merely because somebody
108
+ * mentioned it is a module nobody can reason about.
109
+ */
110
+ export function ensure() {
111
+ if (fs.existsSync(PATH)) return false
112
+ fs.mkdirSync(path.dirname(PATH), { recursive: true })
113
+ fs.writeFileSync(PATH, JSON.stringify({ '//': NOTE, ...DEFAULTS }, null, 2) + '\n')
114
+ return true
115
+ }
116
+
117
+ /** What the doctor prints: where the file is, whether it could be read, and what is in force. */
118
+ export function status() {
119
+ const values = read()
120
+ return { path: PATH, exists: stamp !== null, broken, values }
121
+ }
package/server/watcher.js CHANGED
@@ -162,6 +162,13 @@ export function forgetSession(sessionId) {
162
162
  scans.delete(sessionId)
163
163
  }
164
164
 
165
+ /**
166
+ * Something is really running behind this status. It is the one question two different places
167
+ * ask: whether a status that survives the death of a process would be a lie, and whether a
168
+ * session may be closed from the board — a session that is grinding away is not.
169
+ */
170
+ export const busy = (status) => status === 'WORKING' || status === 'PLANNING'
171
+
165
172
  /**
166
173
  * Translates Claude Code's signals into the board's statuses.
167
174
  * Returns { status, alive }.
@@ -175,7 +182,7 @@ export function deriveStatus(card, live) {
175
182
  // Process dead: the session can still be resumed. We keep the last status only if it said
176
183
  // something about you (an open question, a plan left sitting there); WORKING and PLANNING
177
184
  // would be a lie, nothing is grinding away any more.
178
- const stale = card.status === 'WORKING' || card.status === 'PLANNING' || card.status === 'BACKLOG'
185
+ const stale = busy(card.status) || card.status === 'BACKLOG'
179
186
  return { status: stale ? 'IDLE' : card.status, alive: false }
180
187
  }
181
188