k0-board 0.1.2 → 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 +134 -0
- package/README.md +206 -16
- package/bin/k0-board.js +37 -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 +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 +133 -7
- package/web/board.js +263 -6
- 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
|
@@ -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
|
+
}
|
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
|
|
185
|
+
const stale = busy(card.status) || card.status === 'BACKLOG'
|
|
179
186
|
return { status: stale ? 'IDLE' : card.status, alive: false }
|
|
180
187
|
}
|
|
181
188
|
|
package/server/writer.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { which } from '../platform/shared/run.js'
|
|
5
|
+
import { ROOT } from './paths.js'
|
|
6
|
+
|
|
7
|
+
// ── Who writes the words ─────────────────────────────────────────────────────
|
|
8
|
+
// k0 can work out the facts on its own. It cannot write them up, and it must not learn how:
|
|
9
|
+
// that would mean a key to keep, an account to configure, and a request leaving this machine
|
|
10
|
+
// carrying your commit messages and the names of your repositories. k0 makes no network
|
|
11
|
+
// requests, and that promise is worth more than a better-written paragraph.
|
|
12
|
+
//
|
|
13
|
+
// It does not need to. Everybody running k0 already has Claude Code installed, signed in and
|
|
14
|
+
// paid for — that is the whole premise of the product — so the model is already here. k0 hands
|
|
15
|
+
// it the facts and gets prose back, the same arrangement `/k0-import` has always had, except
|
|
16
|
+
// this one is invisible: no terminal opens and nothing lands on the board. `sessions.js`
|
|
17
|
+
// already drops `claude -p` runs when it looks for sessions to import, so these cannot come
|
|
18
|
+
// back later as cards.
|
|
19
|
+
|
|
20
|
+
// `K0_CLAUDE` names the executable outright. It exists for two reasons: a test must never be
|
|
21
|
+
// able to reach the real Claude Code by accident, and an installation that keeps it somewhere
|
|
22
|
+
// nobody thought of should not be told it has no Claude Code at all. A path that is not there
|
|
23
|
+
// counts as not there, rather than as a promise that fails at the worst moment.
|
|
24
|
+
const CLAUDE = () => {
|
|
25
|
+
const named = process.env.K0_CLAUDE
|
|
26
|
+
if (named) return fs.existsSync(named) ? named : null
|
|
27
|
+
return which('claude', [
|
|
28
|
+
path.join(process.env.HOME || '', '.local/bin/claude'),
|
|
29
|
+
path.join(process.env.HOME || '', '.claude/local/claude'),
|
|
30
|
+
'/opt/homebrew/bin/claude',
|
|
31
|
+
'/usr/local/bin/claude',
|
|
32
|
+
])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SKILL = path.join(ROOT, '.claude', 'skills', 'k0-changelog', 'SKILL.md')
|
|
36
|
+
const TIMEOUT = 3 * 60 * 1000
|
|
37
|
+
const MAX_OUTPUT = 1 << 20
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the write-up can happen at all and, if not, why — in a sentence meant for the page
|
|
41
|
+
* rather than for a log. An adapter never pretends, and neither does this: the facts are shown
|
|
42
|
+
* either way, with the missing half explained instead of quietly absent.
|
|
43
|
+
*/
|
|
44
|
+
export function capability() {
|
|
45
|
+
if (!CLAUDE()) {
|
|
46
|
+
return { can: false, why: 'Claude Code is not on this machine, so there is nobody here to write the summary.' }
|
|
47
|
+
}
|
|
48
|
+
if (!fs.existsSync(SKILL)) {
|
|
49
|
+
return { can: false, why: 'The k0-changelog skill is missing from this copy of k0.' }
|
|
50
|
+
}
|
|
51
|
+
return { can: true, why: null }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// One at a time: a second window of the same page must not start a second model. Each run gets
|
|
55
|
+
// a number, and only the run that is still the current one is allowed to record anything —
|
|
56
|
+
// otherwise a run abandoned halfway would come back later and overwrite the answer somebody is
|
|
57
|
+
// already reading.
|
|
58
|
+
let job = null
|
|
59
|
+
let runs = 0
|
|
60
|
+
|
|
61
|
+
/** Starts the write-up for a window, unless that window is already running or already done. */
|
|
62
|
+
export function write(key, payload) {
|
|
63
|
+
if (job && job.key === key && (job.running || job.text)) return state(key)
|
|
64
|
+
|
|
65
|
+
const can = capability()
|
|
66
|
+
if (!can.can) {
|
|
67
|
+
job = { id: ++runs, key, running: false, text: null, error: can.why, child: null }
|
|
68
|
+
return state(key)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (job?.child) job.child.kill('SIGTERM')
|
|
72
|
+
const id = ++runs
|
|
73
|
+
const child = spawn(CLAUDE(), ['-p', '/k0-changelog', '--output-format', 'text'], {
|
|
74
|
+
cwd: ROOT, // where the project skill lives, whether k0 runs installed or from a checkout
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
})
|
|
77
|
+
job = { id, key, running: true, text: null, error: null, child }
|
|
78
|
+
|
|
79
|
+
/** Records the end of THIS run, once. A run that has already ended cannot end again. */
|
|
80
|
+
const finish = (patch) => {
|
|
81
|
+
if (job?.id !== id || !job.running) return
|
|
82
|
+
job = { ...job, ...patch, running: false, child: null }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let out = ''
|
|
86
|
+
let err = ''
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
child.kill('SIGTERM')
|
|
89
|
+
finish({ error: 'The summary took too long to write and was given up on.' })
|
|
90
|
+
}, TIMEOUT)
|
|
91
|
+
|
|
92
|
+
child.stdout.on('data', (d) => {
|
|
93
|
+
if (out.length < MAX_OUTPUT) out += d
|
|
94
|
+
})
|
|
95
|
+
child.stderr.on('data', (d) => {
|
|
96
|
+
if (err.length < 4096) err += d
|
|
97
|
+
})
|
|
98
|
+
child.on('error', (e) => {
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
finish({ error: `Claude Code would not start: ${e.message}` })
|
|
101
|
+
})
|
|
102
|
+
child.on('close', (code) => {
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
const text = out.trim()
|
|
105
|
+
if (code === 0 && text) finish({ text })
|
|
106
|
+
else finish({ error: err.trim().split('\n').pop() || 'The summary came back empty.' })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// The facts go in on standard input rather than in the prompt: the skill then needs no tools,
|
|
110
|
+
// no permissions and no file of its own, and a month of commits does not have to fit on a
|
|
111
|
+
// command line. Claude Code waits for standard input, so it must always be closed.
|
|
112
|
+
child.stdin.on('error', () => {})
|
|
113
|
+
child.stdin.end(payload)
|
|
114
|
+
|
|
115
|
+
return state(key)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What the page is waiting for. A window nobody has asked about is simply not running. */
|
|
119
|
+
export function state(key) {
|
|
120
|
+
if (!job || job.key !== key) return { running: false, text: null, error: null }
|
|
121
|
+
return { running: !!job.running, text: job.text ?? null, error: job.error ?? null }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Called on the way out, so a model left mid-sentence does not outlive the server. */
|
|
125
|
+
export function stop() {
|
|
126
|
+
if (job?.child) job.child.kill('SIGTERM')
|
|
127
|
+
job = null
|
|
128
|
+
}
|