k0-board 0.3.0 → 0.5.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 +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +426 -1
- package/README.md +1021 -119
- package/bin/k0-board.js +81 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +37 -6
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +3 -1
- package/platform/linux/index.js +14 -0
- package/platform/linux/shell.js +10 -1
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +6 -2
- package/platform/win32/shell.js +4 -1
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +156 -26
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1286 -189
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +404 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +533 -75
- package/web/fuzzy.js +73 -21
- package/web/index.html +109 -12
- package/web/json.js +145 -0
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/server/files.js
CHANGED
|
@@ -19,11 +19,22 @@ const MAX_FILES = 20000 // past that, the listing is cut and says so
|
|
|
19
19
|
const MAX_DEPTH = 12
|
|
20
20
|
const MAX_TEXT = 2 << 20 // 2 MB: above that it is no longer a document to read
|
|
21
21
|
|
|
22
|
-
// Directories that are nobody's work
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
// Directories that are nobody's work and hold nothing anybody reads. They are never even
|
|
23
|
+
// entered: `node_modules` alone is tens of thousands of directories, and this walk is redone
|
|
24
|
+
// every few seconds.
|
|
25
|
+
const SKIP = new Set(['node_modules', 'coverage', 'vendor', 'target', 'Pods', '__pycache__', 'venv'])
|
|
26
|
+
|
|
27
|
+
// Generated too — but generated is not the same as worthless. In a directory of documents this is
|
|
28
|
+
// where the work comes out: the PDFs you print, and the pages you open with a double click. So
|
|
29
|
+
// these are walked into, and the documents in them are listed like any others.
|
|
30
|
+
const GENERATED = new Set(['out', 'dist', 'build'])
|
|
31
|
+
|
|
32
|
+
// A document that is done — the kind of thing a build produces on purpose and a person then
|
|
33
|
+
// prints. It is the narrower half of `DOC_EXT`, and it exists for one case only: a generated
|
|
34
|
+
// directory that git was explicitly told to ignore. There k0 goes behind git's back for the file
|
|
35
|
+
// you went there for, and a PDF is that file; a page is not, because one real site's `dist/` is
|
|
36
|
+
// four thousand of them and none is what you were looking for.
|
|
37
|
+
const FINISHED = new Set(['.pdf', '.docx', '.doc', '.odt', '.pages', '.rtf'])
|
|
27
38
|
|
|
28
39
|
// Only documents go in the listing: things you read and print. In a real repository code is
|
|
29
40
|
// 95% of the files, and among 2,499 `.tsx` files the document you were after is gone. This
|
|
@@ -36,6 +47,43 @@ const DOC_EXT = new Set([
|
|
|
36
47
|
|
|
37
48
|
export const isDoc = (p) => DOC_EXT.has(path.extname(p).toLowerCase())
|
|
38
49
|
|
|
50
|
+
/** A document that was produced to be kept, not a step on the way to one. */
|
|
51
|
+
export const isFinished = (p) => FINISHED.has(path.extname(p).toLowerCase())
|
|
52
|
+
|
|
53
|
+
/** Is one of the directories above this file a generated one? The file itself does not count. */
|
|
54
|
+
export const inGenerated = (p) => p.split('/').slice(0, -1).some((s) => GENERATED.has(s))
|
|
55
|
+
|
|
56
|
+
// The other half of what is worth listing: the files that say how a project is set up. They are
|
|
57
|
+
// not documents — nobody prints an `.env` — but they are the ones you go looking for by hand, and
|
|
58
|
+
// until now the viewer was the only place in k0 you could not reach them from. They stay behind a
|
|
59
|
+
// switch in the page, because in a repository of prose they would only be noise.
|
|
60
|
+
const CONFIG_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
|
|
61
|
+
|
|
62
|
+
// `.env.local` has extension `.local`, and `.env` has no extension at all: this rule reads the
|
|
63
|
+
// name, not the end of it, or the commonest file of the lot would be the one that got away.
|
|
64
|
+
const ENV_NAME = /^\.env(\..+)?$/
|
|
65
|
+
|
|
66
|
+
export const isConfig = (p) =>
|
|
67
|
+
CONFIG_EXT.has(path.extname(p).toLowerCase()) || ENV_NAME.test(path.basename(p))
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything the listing carries: documents to read, configuration to check. It reads the whole
|
|
71
|
+
* path, not the name, because the directory a file is in is half the answer.
|
|
72
|
+
*
|
|
73
|
+
* Out of a generated directory come the documents and nothing else. A document is a document
|
|
74
|
+
* wherever it was made — the page you open with a double click is not worth less than the PDF
|
|
75
|
+
* beside it, and it has to be findable by name like everything else. What stays behind is the
|
|
76
|
+
* configuration a build leaves lying about, which in `out/` is nobody's reading.
|
|
77
|
+
*/
|
|
78
|
+
export const isListed = (p) => (inGenerated(p) ? isDoc(p) : isDoc(p) || isConfig(p))
|
|
79
|
+
|
|
80
|
+
// What can be written back from the page. Configuration, and the notes kept next to it — never
|
|
81
|
+
// code: k0 is not an editor, and a `.js` changed in a browser tab with no undo and no syntax
|
|
82
|
+
// check is a good way to break a project without noticing.
|
|
83
|
+
const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
|
|
84
|
+
|
|
85
|
+
export const isEditable = (p) => NOTE_EXT.has(path.extname(p).toLowerCase()) || isConfig(p)
|
|
86
|
+
|
|
39
87
|
// ── What kind of file is this ─────────────────────────────────────────────────
|
|
40
88
|
const MARKDOWN = new Set(['.md', '.markdown', '.mdx'])
|
|
41
89
|
|
|
@@ -168,16 +216,19 @@ export function safePath(root, rel) {
|
|
|
168
216
|
}
|
|
169
217
|
|
|
170
218
|
/**
|
|
171
|
-
* Which of these paths really exist and are things the viewer can show.
|
|
219
|
+
* Which of these paths really exist and are things the viewer can show, as listing rows.
|
|
172
220
|
*
|
|
173
|
-
* It exists for one thing:
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
221
|
+
* It exists for one thing: a text names files the listing does not carry. Out of a generated
|
|
222
|
+
* directory the listing takes only what is finished, so `out/report.html` — the two megabytes the
|
|
223
|
+
* PDF was printed from — is not in it; and where git rules, an ignored directory is not in it
|
|
224
|
+
* either. Written down by name, both count: the text said so, not the directory.
|
|
177
225
|
*
|
|
178
226
|
* It is not a shortcut for reading the disk: `safePath` keeps everything inside the
|
|
179
227
|
* repository, `isDoc` keeps code out, and hidden paths do not pass — `.claude/` and friends
|
|
180
228
|
* are configuration, not things to read.
|
|
229
|
+
*
|
|
230
|
+
* It answers with rows, not names, because whoever asked is going to draw them next to the
|
|
231
|
+
* others, and a row without its date would be the one that looked wrong.
|
|
181
232
|
*/
|
|
182
233
|
export function exist(root, paths) {
|
|
183
234
|
const out = []
|
|
@@ -187,7 +238,8 @@ export function exist(root, paths) {
|
|
|
187
238
|
const abs = safePath(root, rel)
|
|
188
239
|
if (!abs) continue
|
|
189
240
|
try {
|
|
190
|
-
|
|
241
|
+
const s = fs.statSync(abs)
|
|
242
|
+
if (s.isFile()) out.push(entry(rel, s))
|
|
191
243
|
} catch {
|
|
192
244
|
/* not there: it simply does not come back */
|
|
193
245
|
}
|
|
@@ -213,7 +265,24 @@ const cache = new Map()
|
|
|
213
265
|
*/
|
|
214
266
|
async function scan(root) {
|
|
215
267
|
const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
|
|
216
|
-
return { git:
|
|
268
|
+
if (!names) return { git: false, ...walk(root, isListed) }
|
|
269
|
+
|
|
270
|
+
// git's listing is missing exactly the files all this was built for. An `.env` is ignored by
|
|
271
|
+
// definition, so `ls-files -co --exclude-standard` will never name it, and an `out/` in the
|
|
272
|
+
// `.gitignore` takes the finished PDFs down with it. Both are the file you went there for. So
|
|
273
|
+
// the disk is walked a second time for those two alone — a handful of names, under the same
|
|
274
|
+
// bounds as the other walk — and what git already gave is kept as it is.
|
|
275
|
+
//
|
|
276
|
+
// Only the finished ones, though, and this is the single place where a repository with git
|
|
277
|
+
// reads differently from a folder without. Where git is in charge, git decides: k0 goes behind
|
|
278
|
+
// its back for a PDF, which is a thing somebody went looking for, and not for a page, because a
|
|
279
|
+
// real site's ignored `dist/` is four thousand pages and not one of them is the answer. Where
|
|
280
|
+
// git names the directory itself, everything in it is listed, pages included.
|
|
281
|
+
const fromGit = withStats(root, names)
|
|
282
|
+
const seen = new Set(fromGit.files.map((f) => f.p))
|
|
283
|
+
const extra = walk(root, (p) => (inGenerated(p) ? isFinished(p) : isConfig(p)))
|
|
284
|
+
for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
|
|
285
|
+
return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
|
|
217
286
|
}
|
|
218
287
|
|
|
219
288
|
const tracked = (root) =>
|
|
@@ -226,14 +295,14 @@ function withStats(root, names) {
|
|
|
226
295
|
const files = []
|
|
227
296
|
let truncated = false
|
|
228
297
|
for (const p of names) {
|
|
229
|
-
if (!
|
|
298
|
+
if (!isListed(p)) continue
|
|
230
299
|
if (files.length >= MAX_FILES) {
|
|
231
300
|
truncated = true
|
|
232
301
|
break
|
|
233
302
|
}
|
|
234
303
|
try {
|
|
235
304
|
const s = fs.statSync(path.join(root, p))
|
|
236
|
-
if (s.isFile()) files.push(
|
|
305
|
+
if (s.isFile()) files.push(entry(p, s))
|
|
237
306
|
} catch {
|
|
238
307
|
/* deleted between the listing and now */
|
|
239
308
|
}
|
|
@@ -241,7 +310,21 @@ function withStats(root, names) {
|
|
|
241
310
|
return { files, truncated }
|
|
242
311
|
}
|
|
243
312
|
|
|
244
|
-
|
|
313
|
+
/**
|
|
314
|
+
* One row of the listing. `c` marks a configuration file, so the page can leave it out without
|
|
315
|
+
* having to ask a second time: one byte on the wire, one switch on the other side.
|
|
316
|
+
*/
|
|
317
|
+
const entry = (p, s) => {
|
|
318
|
+
const row = { p, m: Math.floor(s.mtimeMs), s: s.size }
|
|
319
|
+
if (isConfig(p)) row.c = 1
|
|
320
|
+
return row
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Everything under `root` that `pick` accepts — which is asked with the **path**, not the name,
|
|
325
|
+
* because whether a file counts depends on the directory it sits in as much as on its extension.
|
|
326
|
+
*/
|
|
327
|
+
function walk(root, pick) {
|
|
245
328
|
const files = []
|
|
246
329
|
let truncated = false
|
|
247
330
|
|
|
@@ -258,18 +341,20 @@ function walk(root) {
|
|
|
258
341
|
truncated = true
|
|
259
342
|
return
|
|
260
343
|
}
|
|
261
|
-
|
|
344
|
+
// A directory whose name starts with a dot is skipped whole: that one rule removes .git,
|
|
345
|
+
// the caches and the virtual environments in a single stroke. A dotted *file* is not —
|
|
346
|
+
// `.env` is a dotted name, and it is the one this walk was taught to find.
|
|
262
347
|
const child = path.join(dir, e.name)
|
|
263
348
|
const childRel = rel ? `${rel}/${e.name}` : e.name
|
|
264
349
|
if (e.isDirectory()) {
|
|
265
|
-
if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
350
|
+
if (!e.name.startsWith('.') && !SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
266
351
|
continue
|
|
267
352
|
}
|
|
268
353
|
// No symbolic links: a loop would send the walk round forever.
|
|
269
|
-
if (!e.isFile() || !
|
|
354
|
+
if (!e.isFile() || !pick(childRel)) continue
|
|
270
355
|
try {
|
|
271
356
|
const s = fs.statSync(child)
|
|
272
|
-
files.push(
|
|
357
|
+
files.push(entry(childRel, s))
|
|
273
358
|
} catch {
|
|
274
359
|
/* gone in the meantime */
|
|
275
360
|
}
|
|
@@ -377,7 +462,7 @@ function after(line, n) {
|
|
|
377
462
|
/**
|
|
378
463
|
* The files this session has touched: the ones still hanging in the working tree, plus the
|
|
379
464
|
* ones inside commits made since it started. The working tree belongs to everybody and not
|
|
380
|
-
* only to it — the same limit the git mark on the
|
|
465
|
+
* only to it — the same limit the git mark on the post-it already has — but it remains the right
|
|
381
466
|
* answer to the question "what has changed here, now".
|
|
382
467
|
*/
|
|
383
468
|
export async function changed(root, headAtStart) {
|
|
@@ -392,9 +477,9 @@ export async function changed(root, headAtStart) {
|
|
|
392
477
|
.catch(() => []) // the starting commit may not be there any more
|
|
393
478
|
: [],
|
|
394
479
|
])
|
|
395
|
-
//
|
|
396
|
-
// things to read, when there is nothing to read at all.
|
|
397
|
-
return [...new Set([...now, ...mine])].filter(
|
|
480
|
+
// What the listing carries, here too: a "Changed 40" made of `.tsx` files would say there are
|
|
481
|
+
// forty things to read, when there is nothing to read at all.
|
|
482
|
+
return [...new Set([...now, ...mine])].filter(isListed)
|
|
398
483
|
}
|
|
399
484
|
|
|
400
485
|
// ── Searching inside the files ────────────────────────────────────────────────
|
|
@@ -406,6 +491,8 @@ export async function changed(root, headAtStart) {
|
|
|
406
491
|
// of a few KB, all already in the operating system's cache — it costs less than keeping an
|
|
407
492
|
// index up to date, and an index goes stale.
|
|
408
493
|
const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
|
|
494
|
+
/** Read only when the page asks for them: they are hidden by default, and so are their lines. */
|
|
495
|
+
const TEXTY_CONFIG = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
|
|
409
496
|
const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
|
|
410
497
|
const GREP_BYTES = 1 << 20
|
|
411
498
|
const SNIPPET = 200
|
|
@@ -421,8 +508,13 @@ export const plain = (s) =>
|
|
|
421
508
|
.toLowerCase()
|
|
422
509
|
.replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
|
|
423
510
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
511
|
+
/**
|
|
512
|
+
* The documents that contain all the words searched for, with the line they appear on.
|
|
513
|
+
*
|
|
514
|
+
* With `nerd` the configuration files are looked inside as well. It has to follow what the page
|
|
515
|
+
* is showing: finding a line in a file the listing is hiding would be a result you cannot open.
|
|
516
|
+
*/
|
|
517
|
+
export async function grep(root, query, nerd = false) {
|
|
426
518
|
const words = plain(query).split(/\s+/).filter(Boolean)
|
|
427
519
|
if (!words.length) return []
|
|
428
520
|
|
|
@@ -430,7 +522,10 @@ export async function grep(root, query) {
|
|
|
430
522
|
const hits = []
|
|
431
523
|
for (const f of files) {
|
|
432
524
|
if (hits.length >= GREP_FILES) break
|
|
433
|
-
if (
|
|
525
|
+
if (f.c && !nerd) continue
|
|
526
|
+
const ext = path.extname(f.p).toLowerCase()
|
|
527
|
+
const texty = TEXTY.has(ext) || (nerd && (TEXTY_CONFIG.has(ext) || ENV_NAME.test(path.basename(f.p))))
|
|
528
|
+
if (!texty || f.s > GREP_BYTES) continue
|
|
434
529
|
let text
|
|
435
530
|
try {
|
|
436
531
|
text = fs.readFileSync(path.join(root, f.p), 'utf8')
|
|
@@ -484,5 +579,40 @@ export function read(abs) {
|
|
|
484
579
|
return { kind, ...meta, text, truncated }
|
|
485
580
|
}
|
|
486
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Writes the file back, or refuses.
|
|
584
|
+
*
|
|
585
|
+
* `was` is the modification time the page was handed when it opened the file. If the file on disk
|
|
586
|
+
* has moved on since, the write does not happen: somebody else — a session, an editor, a script —
|
|
587
|
+
* has written it in the meantime, and overwriting silently would throw their work away. The page
|
|
588
|
+
* is told, and it still has what you typed.
|
|
589
|
+
*
|
|
590
|
+
* The bytes go to a neighbouring name and are then renamed over the original, which on the same
|
|
591
|
+
* filesystem is one indivisible step: an interrupted save leaves the old file whole rather than
|
|
592
|
+
* half the new one. The mode is carried over, or a `.env` at 600 would come back readable by all.
|
|
593
|
+
*/
|
|
594
|
+
export function write(abs, text, was) {
|
|
595
|
+
const s = fs.statSync(abs)
|
|
596
|
+
if (!s.isFile()) throw new Error('Not a file')
|
|
597
|
+
if (Math.floor(s.mtimeMs) !== was) {
|
|
598
|
+
const clash = new Error('This file changed on disk while you were editing it. Nothing was saved.')
|
|
599
|
+
clash.conflict = true
|
|
600
|
+
throw clash
|
|
601
|
+
}
|
|
602
|
+
const tmp = `${abs}.k0-tmp`
|
|
603
|
+
try {
|
|
604
|
+
fs.writeFileSync(tmp, text, { mode: s.mode & 0o777 })
|
|
605
|
+
fs.renameSync(tmp, abs)
|
|
606
|
+
} catch (e) {
|
|
607
|
+
fs.rmSync(tmp, { force: true })
|
|
608
|
+
throw e
|
|
609
|
+
}
|
|
610
|
+
const now = fs.statSync(abs)
|
|
611
|
+
return { size: now.size, mtime: Math.floor(now.mtimeMs) }
|
|
612
|
+
}
|
|
613
|
+
|
|
487
614
|
/** Show it in the file manager: the only way to open what cannot be shown here. */
|
|
488
615
|
export const reveal = (abs) => shell.revealInFileManager(abs)
|
|
616
|
+
|
|
617
|
+
/** A folder is opened rather than pointed at: what you want is to be inside it. */
|
|
618
|
+
export const openFolder = (abs) => shell.openInFileManager(abs)
|
package/server/git.js
CHANGED
|
@@ -13,12 +13,27 @@ import { parseChanged } from './files.js'
|
|
|
13
13
|
// "did I push it?" that is exact and instant — pushing updates the remote reference by itself
|
|
14
14
|
// — and in exchange it never touches the network, your credentials, or your patience.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where git is on this machine, or null when it is nowhere k0 knows to look.
|
|
18
|
+
*
|
|
19
|
+
* Exported because `worktree.js` shells out to the same git. A second copy of this list is a
|
|
20
|
+
* second thing to remember on the day a layout nobody thought of has to be added to it, and the
|
|
21
|
+
* half that was forgotten does not fail loudly — it reports that the machine has no git at all.
|
|
22
|
+
*/
|
|
23
|
+
export const gitPath = () =>
|
|
24
|
+
which('git', ['/usr/bin/git', '/usr/local/bin/git', 'C:\\Program Files\\Git\\cmd\\git.exe'])
|
|
25
|
+
|
|
26
|
+
const available = () => !!gitPath()
|
|
18
27
|
|
|
19
28
|
const OPTS = { timeout: 4000, maxBuffer: 1 << 20 }
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
// How long a reading is worth before it is taken again. Five seconds was a guess about how fast a
|
|
31
|
+
// repository changes, and the answer is: far slower than that. Nothing here moves except when you
|
|
32
|
+
// commit, and a mark half a minute old has never been wrong about whether there is work in the
|
|
33
|
+
// tree. At five seconds the loop was spawning a couple of git processes per repository per round,
|
|
34
|
+
// which on a real board was the single biggest thing k0 did all day — see the call in `index.js`,
|
|
35
|
+
// which now also stops asking at all when nobody is looking.
|
|
36
|
+
const TTL = 30000
|
|
22
37
|
// The last column of the board lists every repository, which is dozens of them and none of
|
|
23
38
|
// them working: those are looked at rarely and a few at a time. At five seconds it would be
|
|
24
39
|
// eighty git processes every round, on a machine that is already struggling.
|
|
@@ -56,7 +71,7 @@ export function parseStatus(out) {
|
|
|
56
71
|
* How many of those unpushed commits arrived AFTER the session started. The list is
|
|
57
72
|
* newest-first, so they are the ones before `headAtStart`. If `headAtStart` is not in the list
|
|
58
73
|
* it was already pushed, and all of them are the session's. Returns null when it cannot be
|
|
59
|
-
* known, and then the
|
|
74
|
+
* known, and then the post-it says nothing about it.
|
|
60
75
|
*/
|
|
61
76
|
export function sessionShare(shas, headAtStart) {
|
|
62
77
|
if (!Array.isArray(shas) || !shas.length) return 0
|
|
@@ -67,7 +82,7 @@ export function sessionShare(shas, headAtStart) {
|
|
|
67
82
|
|
|
68
83
|
async function hasRemote(dir) {
|
|
69
84
|
if (remotes.has(dir)) return remotes.get(dir)
|
|
70
|
-
const yes = await run(
|
|
85
|
+
const yes = await run(gitPath(), ['-C', dir, 'remote'], OPTS)
|
|
71
86
|
.then((out) => !!out.trim())
|
|
72
87
|
.catch(() => false)
|
|
73
88
|
remotes.set(dir, yes)
|
|
@@ -79,12 +94,18 @@ async function read(dir) {
|
|
|
79
94
|
// destroyed. Costs nothing and saves launching git for nothing every five seconds.
|
|
80
95
|
if (!fs.existsSync(path.join(dir, '.git'))) return null
|
|
81
96
|
|
|
97
|
+
// Counted here and not where the reading was scheduled: most of a board's directories are
|
|
98
|
+
// worktrees that no longer exist, and those turn back on the line above without ever reaching
|
|
99
|
+
// git. A number that counted them too would put three figures in front of somebody on a machine
|
|
100
|
+
// running almost no git at all.
|
|
101
|
+
taken.push(Date.now())
|
|
102
|
+
|
|
82
103
|
// The three in parallel: two processes in a queue would double the wait for nothing. After
|
|
83
104
|
// the first round `hasRemote` answers from memory and it is back to two processes.
|
|
84
105
|
const [st, list, remote] = await Promise.all([
|
|
85
|
-
run(
|
|
106
|
+
run(gitPath(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '--branch'], OPTS).catch(() => null),
|
|
86
107
|
run(
|
|
87
|
-
|
|
108
|
+
gitPath(),
|
|
88
109
|
['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, 'HEAD', '--not', '--remotes'],
|
|
89
110
|
OPTS
|
|
90
111
|
)
|
|
@@ -101,6 +122,18 @@ async function read(dir) {
|
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
|
125
|
+
// When each reading was taken, kept for a minute. Every reading is two git processes — three the
|
|
126
|
+
// first time, before `hasRemote` knows — and this number, which used to be in the hundreds, is the
|
|
127
|
+
// one worth putting where somebody can see it.
|
|
128
|
+
const taken = []
|
|
129
|
+
|
|
130
|
+
/** Readings started in the last minute. */
|
|
131
|
+
export function recent() {
|
|
132
|
+
const since = Date.now() - 60000
|
|
133
|
+
while (taken.length && taken[0] < since) taken.shift()
|
|
134
|
+
return taken.length
|
|
135
|
+
}
|
|
136
|
+
|
|
104
137
|
function schedule(dir) {
|
|
105
138
|
const e = cache.get(dir) ?? { at: 0, running: false, value: null }
|
|
106
139
|
e.running = true
|
|
@@ -120,15 +153,21 @@ function schedule(dir) {
|
|
|
120
153
|
* The directories that matter right now. Refreshes the stale ones and forgets the ones no
|
|
121
154
|
* longer needed — a destroyed worktree disappears by itself. Waits for nothing: whoever asks
|
|
122
155
|
* for the state gets what is there, and the board never stalls on git.
|
|
156
|
+
*
|
|
157
|
+
* `eager` is which of them to actually ask about, and it is a separate question from which to
|
|
158
|
+
* keep: away from the board only the repositories a session is working in are worth asking, but
|
|
159
|
+
* dropping the rest out of the cache would mean re-reading every one of them the moment you looked
|
|
160
|
+
* again — a burst of a couple of hundred readings for a glance. What is kept and what is refreshed
|
|
161
|
+
* are two lists, and only the second one shrinks.
|
|
123
162
|
*/
|
|
124
|
-
export function watch(dirs, slow = []) {
|
|
163
|
+
export function watch(dirs, slow = [], eager = dirs) {
|
|
125
164
|
if (!available()) return
|
|
126
165
|
const hot = new Set(dirs.filter(Boolean))
|
|
127
166
|
const cold = new Set(slow.filter(Boolean).filter((d) => !hot.has(d)))
|
|
128
167
|
const wanted = new Set([...hot, ...cold])
|
|
129
168
|
for (const dir of [...cache.keys()]) if (!wanted.has(dir)) cache.delete(dir)
|
|
130
169
|
|
|
131
|
-
for (const dir of hot) due(dir, TTL)
|
|
170
|
+
for (const dir of new Set(eager.filter(Boolean))) if (hot.has(dir)) due(dir, TTL)
|
|
132
171
|
// The repositories that are only in the list: a few per round, so within ten seconds they
|
|
133
172
|
// are all done and then it stays quiet for a minute.
|
|
134
173
|
let budget = SLOW_BATCH
|
|
@@ -154,7 +193,7 @@ export function stateOf(dir) {
|
|
|
154
193
|
/** Where HEAD is at this moment. Needed once, when a session starts. */
|
|
155
194
|
export async function head(dir) {
|
|
156
195
|
if (!available()) return null
|
|
157
|
-
return run(
|
|
196
|
+
return run(gitPath(), ['--no-optional-locks', '-C', dir, 'rev-parse', 'HEAD'], OPTS)
|
|
158
197
|
.then((out) => out.trim() || null)
|
|
159
198
|
.catch(() => null)
|
|
160
199
|
}
|
|
@@ -225,7 +264,7 @@ export function stripTrailers(body) {
|
|
|
225
264
|
const authors = new Map()
|
|
226
265
|
export async function authorEmail(dir) {
|
|
227
266
|
if (authors.has(dir)) return authors.get(dir)
|
|
228
|
-
const email = await run(
|
|
267
|
+
const email = await run(gitPath(), ['-C', dir, 'config', 'user.email'], OPTS)
|
|
229
268
|
.then((out) => out.trim())
|
|
230
269
|
.catch(() => '')
|
|
231
270
|
authors.set(dir, email)
|
|
@@ -261,9 +300,9 @@ export async function history(dir, { from, to }) {
|
|
|
261
300
|
if (email) args.push(`--author=${email}`)
|
|
262
301
|
|
|
263
302
|
const [log, list, remote] = await Promise.all([
|
|
264
|
-
run(
|
|
303
|
+
run(gitPath(), args, LOG_OPTS).catch(() => ''), // a repository without a single commit
|
|
265
304
|
run(
|
|
266
|
-
|
|
305
|
+
gitPath(),
|
|
267
306
|
['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, '--branches', '--not', '--remotes'],
|
|
268
307
|
OPTS
|
|
269
308
|
)
|
|
@@ -286,7 +325,7 @@ export async function history(dir, { from, to }) {
|
|
|
286
325
|
export async function dirtyFiles(dir) {
|
|
287
326
|
if (!available()) return []
|
|
288
327
|
if (!fs.existsSync(path.join(dir, '.git'))) return []
|
|
289
|
-
return run(
|
|
328
|
+
return run(gitPath(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '-z'], OPTS)
|
|
290
329
|
.then((out) => parseChanged(out).slice(0, MAX_DIRTY))
|
|
291
330
|
.catch(() => [])
|
|
292
331
|
}
|
package/server/idle.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Which terminals have been sitting there long enough to be worth giving back.
|
|
3
3
|
*
|
|
4
4
|
* A session left open costs memory whether or not anybody is looking at it: the `claude` process,
|
|
5
|
-
* the MCP servers it started, the browsers those opened. `Close` on a
|
|
6
|
-
* cure — it stops the session and shuts its window, leaves the
|
|
5
|
+
* the MCP servers it started, the browsers those opened. `Close` on a post-it has always been the
|
|
6
|
+
* cure — it stops the session and shuts its window, leaves the story exactly where it is, and
|
|
7
7
|
* `Resume` picks the conversation up where it was. This is that same gesture, remembered for you.
|
|
8
8
|
*
|
|
9
9
|
* Only the deciding lives here. Nothing in this file kills anything, opens anything or reads a
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const HOUR = 3600000
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* The
|
|
16
|
+
* The stories whose terminal should go, most of the rules being about what to leave alone.
|
|
17
17
|
*
|
|
18
18
|
* Yellow only — `IDLE`, the ball in your court. Not because the others are less forgotten but
|
|
19
19
|
* because of what closing them would destroy: a question and a finished plan are drawn by the
|
|
@@ -22,15 +22,15 @@ const HOUR = 3600000
|
|
|
22
22
|
* Code reports as `shell` is a shell you dropped into, which may well have a command of yours
|
|
23
23
|
* running in it — k0 does not know what, so k0 does not touch it.
|
|
24
24
|
*/
|
|
25
|
-
export function dueForClose({
|
|
25
|
+
export function dueForClose({ stories, live, hours, now = Date.now() }) {
|
|
26
26
|
if (!(hours > 0)) return [] // switched off, and nothing to think about
|
|
27
27
|
const deadline = now - hours * HOUR
|
|
28
|
-
return
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
const session = live.get(
|
|
28
|
+
return stories.filter((story) => {
|
|
29
|
+
if (story.completed_at || !story.session_id || !story.session_alive) return false
|
|
30
|
+
if (story.status !== 'IDLE') return false
|
|
31
|
+
const session = live.get(story.session_id)
|
|
32
32
|
if (!session || session.status !== 'idle') return false
|
|
33
|
-
return lastSignOfLife(
|
|
33
|
+
return lastSignOfLife(story, session) <= deadline
|
|
34
34
|
})
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -48,9 +48,11 @@ export function dueForClose({ cards, live, hours, now = Date.now() }) {
|
|
|
48
48
|
* changes state and are not a heartbeat — a session that had been busy for twenty-three minutes
|
|
49
49
|
* was carrying timestamps twenty-three minutes old — so they say what they appear to say.
|
|
50
50
|
*
|
|
51
|
-
* `updated_at` matters for the case none of the others cover: resuming a
|
|
52
|
-
* `IDLE`, so no new event is written and `status_since` stays as old as it was.
|
|
51
|
+
* `updated_at` matters for the case none of the others cover: resuming a story leaves the status
|
|
52
|
+
* at `IDLE`, so no new event is written and `status_since` stays as old as it was. It is also why
|
|
53
|
+
* a session changing status still touches the story's `updated_at` now that the two live in
|
|
54
|
+
* different tables — see `applyDerivedStatus` in db.js.
|
|
53
55
|
*/
|
|
54
|
-
export function lastSignOfLife(
|
|
55
|
-
return Math.max(
|
|
56
|
+
export function lastSignOfLife(story, session) {
|
|
57
|
+
return Math.max(story.updated_at || 0, story.status_since || 0, session.updatedAt || 0, session.statusUpdatedAt || 0)
|
|
56
58
|
}
|