k0-board 0.1.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.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
package/server/pdf.js ADDED
@@ -0,0 +1,110 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { CACHE_DIR } from './paths.js'
6
+ import { shell } from '../platform/index.js'
7
+
8
+ // ── The document on paper ─────────────────────────────────────────────────────
9
+ // The PDF is made by the browser, because the page has to come out the same as what you see:
10
+ // same layout engine, same print CSS, same fonts. Writing one here would mean a large
11
+ // dependency and a worse result.
12
+ //
13
+ // So a headless Chrome is opened on the viewer's page and asked to print it to a file. The
14
+ // difference from ⌘P is that here we decide the margins and what the browser adds:
15
+ // `--no-pdf-header-footer` removes the date, the address and the page number, and the margin
16
+ // is the one in the CSS `@page`. From the print dialog, whatever the user picked in there wins.
17
+ //
18
+ // Two things learned by trying it, which explain the code below:
19
+ // 1. Chrome writes the PDF and THEN stays up. It does not exit on its own: it has to be
20
+ // stopped as soon as it is done, or every download leaves a process hanging around.
21
+ // 2. Two Chromes on the same profile directory wait for each other forever. So requests are
22
+ // served one at a time, in a queue.
23
+
24
+ /** The browser that prints, or nothing. Printing and downloading the raw file do not use it. */
25
+ export const browser = () => shell.findChrome()
26
+
27
+ // A profile of its own, kept aside: without one, Chrome would attach to the user's real
28
+ // profile. Keeping it between runs saves a few seconds.
29
+ const PROFILE = path.join(CACHE_DIR, 'chrome')
30
+
31
+ const TIMEOUT = 30000 // past this, something went wrong and nobody is waiting any more
32
+ /** How Chrome says it has finished, on its error channel. */
33
+ const DONE = /bytes written to file/i
34
+
35
+ /** The queue: one print at a time, so two Chromes never fight over the same profile. */
36
+ let queue = Promise.resolve()
37
+
38
+ /**
39
+ * The page at `url`, printed, as the bytes of a PDF.
40
+ * Any address this server can serve will do.
41
+ */
42
+ export function render(url) {
43
+ const mine = queue.then(
44
+ () => once(url),
45
+ () => once(url)
46
+ )
47
+ // The queue must never break: if one print goes wrong, the next one still starts.
48
+ queue = mine.catch(() => {})
49
+ return mine
50
+ }
51
+
52
+ function once(url) {
53
+ const bin = browser()
54
+ if (!bin) throw new Error('Install Google Chrome or Chromium to download PDFs')
55
+
56
+ const out = path.join(os.tmpdir(), `k0-${process.pid}-${Date.now()}.pdf`)
57
+ fs.mkdirSync(PROFILE, { recursive: true })
58
+
59
+ const child = spawn(bin, [
60
+ '--headless',
61
+ '--disable-gpu',
62
+ '--no-first-run',
63
+ '--no-default-browser-check',
64
+ '--disable-background-networking',
65
+ '--disable-sync',
66
+ '--disable-extensions',
67
+ '--no-pdf-header-footer',
68
+ // The document arrives after the page has loaded, and without this Chrome would print the
69
+ // blank sheet from before. It is not a real wait: Chrome runs the clock itself and stops
70
+ // as soon as nothing more is coming.
71
+ '--virtual-time-budget=8000',
72
+ `--user-data-dir=${PROFILE}`,
73
+ `--print-to-pdf=${out}`,
74
+ url,
75
+ ])
76
+
77
+ return new Promise((resolve, reject) => {
78
+ let over = false
79
+ const end = (err, buf) => {
80
+ if (over) return
81
+ over = true
82
+ clearTimeout(timer)
83
+ child.kill('SIGKILL')
84
+ try {
85
+ fs.unlinkSync(out)
86
+ } catch {} // if it was never written there is nothing to remove
87
+ err ? reject(err) : resolve(buf)
88
+ }
89
+
90
+ const timer = setTimeout(() => end(new Error('The PDF took too long')), TIMEOUT)
91
+
92
+ // The signal that it is finished comes from here, not from the process exiting: Chrome
93
+ // writes the file and then stays up, and waiting for it to close would mean waiting forever.
94
+ let log = ''
95
+ child.stderr.on('data', (c) => {
96
+ log = (log + c).slice(-4000)
97
+ if (!DONE.test(log)) return
98
+ try {
99
+ end(null, fs.readFileSync(out))
100
+ } catch (e) {
101
+ end(e)
102
+ }
103
+ })
104
+
105
+ child.on('error', (e) => end(new Error(`The browser did not start: ${e.message}`)))
106
+ // If instead it exits before having written anything, there is no PDF: better to say so
107
+ // now than to sit still until the time runs out.
108
+ child.on('exit', () => setTimeout(() => end(new Error('The PDF did not come out')), 200))
109
+ })
110
+ }
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { projectRecency } from './db.js'
5
+ import { hasDocs } from './files.js'
6
+ import { shell } from '../platform/index.js'
7
+
8
+ const HOME = os.homedir()
9
+ const CLAUDE_JSON = path.join(HOME, '.claude.json')
10
+
11
+ /** Still there? A renamed or deleted directory is no longer a project. */
12
+ function exists(dir) {
13
+ try {
14
+ return fs.statSync(dir).isDirectory()
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Is it empty? An empty directory under your home is one you have just made, and it cannot be
22
+ * anything other than a project about to start: it is the sign installed software never has.
23
+ * Entries are read one at a time rather than with `readdirSync`, which for a directory full of
24
+ * things would drag the whole lot along on every round of the board. Anything starting with a
25
+ * dot does not count: the metadata file the file manager scatters the moment you walk past
26
+ * must not make an empty directory look full.
27
+ */
28
+ function isEmpty(dir) {
29
+ let d
30
+ try {
31
+ d = fs.opendirSync(dir)
32
+ } catch {
33
+ return false
34
+ }
35
+ try {
36
+ for (;;) {
37
+ const e = d.readSync()
38
+ if (!e) return true
39
+ if (!e.name.startsWith('.')) return false
40
+ }
41
+ } catch {
42
+ return false
43
+ } finally {
44
+ try {
45
+ d.closeSync()
46
+ } catch {
47
+ /* already closed: does not change the answer */
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * When you last opened each project according to Claude Code, which keeps it in
54
+ * ~/.claude.json. Careful: `lastStartTime` is rewritten when the session ENDS, so on its own
55
+ * it only tells you about the past — the present is k0's to know (see below).
56
+ */
57
+ function lastUsed() {
58
+ const map = new Map()
59
+ try {
60
+ const data = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf8'))
61
+ for (const [p, v] of Object.entries(data.projects || {})) {
62
+ const t = Number(v?.lastStartTime) || Number(v?.lastSessionModified) || 0
63
+ if (t) map.set(p, t)
64
+ }
65
+ } catch {
66
+ /* no history: fall back to alphabetical order */
67
+ }
68
+ return map
69
+ }
70
+
71
+ /**
72
+ * The candidate directories: everything under your home, plus the ones Claude Code has seen
73
+ * elsewhere. No cache and no filtering on `.git`: a directory just created or just renamed has
74
+ * to show up with the right name on the first refresh of the page, and reading the home
75
+ * directory costs far too little to put off.
76
+ */
77
+ function scan(fromClaude) {
78
+ const paths = new Set()
79
+ const skip = shell.homeSkipList()
80
+
81
+ for (const name of fs.readdirSync(HOME)) {
82
+ if (name.startsWith('.') || skip.has(name)) continue
83
+ const dir = path.join(HOME, name)
84
+ if (exists(dir)) paths.add(dir)
85
+ }
86
+ for (const dir of fromClaude) {
87
+ // No worktrees: they are temporary copies, not projects.
88
+ if (dir.includes(`${path.sep}.claude-worktrees${path.sep}`) || dir.includes('/.claude-worktrees/')) continue
89
+ if (exists(dir)) paths.add(dir)
90
+ }
91
+
92
+ return [...paths]
93
+ }
94
+
95
+ /**
96
+ * The repositories, most used at the top. Recency is the fresher of two histories: Claude
97
+ * Code's, which knows where you have worked even outside k0, and k0's own cards, which are the
98
+ * only thing that notices a session still open right now.
99
+ */
100
+ export function listProjects() {
101
+ const fromClaude = lastUsed()
102
+ const mine = new Map(projectRecency().map((r) => [r.project_path, Number(r.at) || 0]))
103
+ // A repository with a card is always selectable, even if it lives outside your home — but
104
+ // only while its directory exists: the old name of a renamed one does not come back.
105
+ const paths = new Set([...scan(fromClaude.keys()), ...[...mine.keys()].filter(exists)])
106
+ // Your home is not a project: it is the directory that contains them all. It ends up here
107
+ // because Claude Code notes a session started from it in ~/.claude.json too, but as a
108
+ // repository it means nothing — and in the list it would sit among its own children.
109
+ paths.delete(HOME)
110
+
111
+ const list = [...paths].map((dir) => ({
112
+ name: path.basename(dir),
113
+ path: dir,
114
+ last_used: Math.max(fromClaude.get(dir) || 0, mine.get(dir) || 0),
115
+ // Your stuff, or installed stuff? One sign is enough: a `.git`, a history in Claude Code, a
116
+ // card, a document in there, or the opposite of all of them — that there is nothing in it
117
+ // yet. Under your home there are also directories that are only ever programs, and those
118
+ // stay out precisely because they are full of things nobody reads.
119
+ // The empty question is asked last: it touches the disk, and by then only the few
120
+ // directories no other sign has already claimed still ask it.
121
+ known:
122
+ fromClaude.has(dir) ||
123
+ mine.has(dir) ||
124
+ fs.existsSync(path.join(dir, '.git')) ||
125
+ hasDocs(dir) ||
126
+ isEmpty(dir),
127
+ }))
128
+ list.sort((a, b) => b.last_used - a.last_used || a.name.localeCompare(b.name))
129
+ return list
130
+ }
131
+
132
+ /**
133
+ * The cards still standing: the ones whose directory exists. It is the same rule as the
134
+ * repository list above — "a card is kept alive by its directory" — carried over to the board
135
+ * too, which used to work its columns out from the cards alone. Without it, a deleted or
136
+ * renamed directory leaves a column behind forever.
137
+ *
138
+ * Hide, do not delete: the cards stay in the database, and if the directory comes back one day
139
+ * — an external disk remounted, a directory put back — so do they.
140
+ *
141
+ * The disk is asked once per repository and not once per card: there are a couple of dozen
142
+ * columns and a few hundred cards, and this round runs every second.
143
+ */
144
+ export function onDisk(cards) {
145
+ const alive = new Map()
146
+ return cards.filter((c) => {
147
+ if (!alive.has(c.project_path)) alive.set(c.project_path, exists(c.project_path))
148
+ return alive.get(c.project_path)
149
+ })
150
+ }
151
+
152
+ export const projectName = (p) => path.basename(p)
@@ -0,0 +1,341 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { StringDecoder } from 'node:string_decoder'
5
+ import { PROJECTS_DIR } from './watcher.js'
6
+
7
+ /**
8
+ * Claude Code sessions that have already happened, ready to become cards.
9
+ *
10
+ * Nothing is written here: it only reads ~/.claude/projects, where Claude Code keeps the
11
+ * transcript of every session in a JSONL file.
12
+ */
13
+
14
+ /** How much is read from the head of a transcript to decide whether to open all of it. */
15
+ const HEAD = 64 * 1024
16
+
17
+ /**
18
+ * The mark of a real session.
19
+ *
20
+ * Claude Code's interface writes a `{"type":"mode",…}` line; a `claude -p`, a subagent or a
21
+ * skill launched from a script does not. It is a clean split — on one real machine it
22
+ * separated 145 genuine sessions from 867 automated runs — and it can be read without opening
23
+ * the whole file.
24
+ *
25
+ * The line is usually at the top, but not always: if you paste the prompt while Claude is
26
+ * still starting up, the transcript begins with the lines of the send queue and the `mode`
27
+ * ends up further down. Which is why the end is checked too — without that check, real
28
+ * sessions were being lost, and the best ones at that: a pasted document and a question.
29
+ */
30
+ const MODE = /^\{"type":"mode"/m
31
+
32
+ /** The bite a transcript is read in: some of them run to megabytes. */
33
+ const CHUNK = 1 << 20
34
+
35
+ const CLIP_FIRST = 700
36
+ const CLIP_RECENT = 250
37
+ const CLIP_REPLY = 500
38
+ const RECENT = 3
39
+
40
+ /**
41
+ * Opens and reads a piece of a file. Returns an empty string if the file disappeared in the
42
+ * meantime: a scan must never stall over one transcript fewer.
43
+ */
44
+ function slice(file, from, len) {
45
+ let fd
46
+ try {
47
+ fd = fs.openSync(file, 'r')
48
+ } catch {
49
+ return ''
50
+ }
51
+ try {
52
+ const buf = Buffer.alloc(len)
53
+ const n = fs.readSync(fd, buf, 0, len, from)
54
+ return buf.subarray(0, n).toString('utf8')
55
+ } catch {
56
+ return ''
57
+ } finally {
58
+ fs.closeSync(fd)
59
+ }
60
+ }
61
+
62
+ const CWD = /"cwd":"((?:[^"\\]|\\.)*)"/
63
+
64
+ function firstCwd(text) {
65
+ const m = CWD.exec(text)
66
+ if (!m) return null
67
+ try {
68
+ return JSON.parse(`"${m[1]}"`)
69
+ } catch {
70
+ return null // string cut in half by the edge of the window
71
+ }
72
+ }
73
+
74
+ /**
75
+ * From the `cwd` to a column on the board. It walks up to the first `.git`; if that is a file
76
+ * rather than a directory we are inside a worktree, and then the work belongs to the
77
+ * repository the worktree came from — not to the temporary copy, which tomorrow will not be
78
+ * there. With no `.git` anywhere it falls back to the directory under your home.
79
+ */
80
+ function resolveRoot(cwd) {
81
+ let dir = cwd
82
+ for (;;) {
83
+ const dot = path.join(dir, '.git')
84
+ let st = null
85
+ try {
86
+ st = fs.statSync(dot)
87
+ } catch {
88
+ /* nothing here: go up */
89
+ }
90
+ if (st?.isDirectory()) return dir
91
+ if (st?.isFile()) {
92
+ const m = /gitdir:\s*(.+)/.exec(fs.readFileSync(dot, 'utf8'))
93
+ const base = m?.[1].trim().split('/.git/worktrees/')[0]
94
+ return base && fs.existsSync(base) ? base : dir
95
+ }
96
+ const up = path.dirname(dir)
97
+ if (up === dir) break
98
+ dir = up
99
+ }
100
+ // No `.git` anywhere: to k0 a project is also just a directory you have worked in — a
101
+ // folder of notes is not a repository and belongs on the board like everything else.
102
+ // The directory under your home is what counts, so subdirectories do not each open a
103
+ // column of their own.
104
+ const home = os.homedir()
105
+ if (cwd.startsWith(home + path.sep)) {
106
+ const first = cwd.slice(home.length + 1).split(path.sep)[0]
107
+ if (first) return path.join(home, first)
108
+ }
109
+ return cwd
110
+ }
111
+
112
+ function repoOf(cwd, cache) {
113
+ if (cache.has(cwd)) return cache.get(cwd)
114
+ const r = resolveRoot(cwd)
115
+ const ok =
116
+ r && fs.existsSync(r) && !r.includes('/.claude/worktrees/') && !r.includes('/.claude-worktrees/') ? r : null
117
+ cache.set(cwd, ok)
118
+ return ok
119
+ }
120
+
121
+ /** Walks a transcript line by line without holding all of it in memory. */
122
+ function eachLine(file, fn) {
123
+ let fd
124
+ try {
125
+ fd = fs.openSync(file, 'r')
126
+ } catch {
127
+ return
128
+ }
129
+ const buf = Buffer.alloc(CHUNK)
130
+ const decoder = new StringDecoder('utf8')
131
+ let rest = ''
132
+ try {
133
+ let pos = 0
134
+ for (;;) {
135
+ const n = fs.readSync(fd, buf, 0, CHUNK, pos)
136
+ if (!n) break
137
+ pos += n
138
+ const parts = (rest + decoder.write(buf.subarray(0, n))).split('\n')
139
+ rest = parts.pop() ?? ''
140
+ for (const line of parts) fn(line)
141
+ }
142
+ rest += decoder.end()
143
+ if (rest) fn(rest)
144
+ } finally {
145
+ fs.closeSync(fd)
146
+ }
147
+ }
148
+
149
+ /** Lines that are not your words but the program's scaffolding. */
150
+ const NOISE =
151
+ /^(<command-name>|<command-message>|<command-args>|<local-command|<user-prompt-submit-hook|<system-reminder>|<bash-input>|<bash-stdout>|Caveat: The messages below|\[Request interrupted)/
152
+
153
+ function textOf(o) {
154
+ const c = o.message?.content
155
+ if (typeof c === 'string') return c
156
+ if (!Array.isArray(c)) return ''
157
+ return c
158
+ .filter((x) => x?.type === 'text' && typeof x.text === 'string')
159
+ .map((x) => x.text)
160
+ .join('\n')
161
+ }
162
+
163
+ function clean(s) {
164
+ return s
165
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
166
+ .replace(/[ \t]+\n/g, '\n')
167
+ .replace(/\n{3,}/g, '\n\n')
168
+ .trim()
169
+ }
170
+
171
+ const clip = (s, n) => (s.length > n ? s.slice(0, n).trimEnd() + '…' : s)
172
+
173
+ /**
174
+ * The gist of a session: enough to say what it was about and where it got to, without
175
+ * dragging megabytes of conversation along.
176
+ */
177
+ function digest(file) {
178
+ const out = {
179
+ turns: 0,
180
+ first_prompt: '',
181
+ recent_prompts: [],
182
+ last_reply: '',
183
+ title_hint: null,
184
+ started_at: null,
185
+ ended_at: null,
186
+ }
187
+ let aiTitle = null
188
+ let custom = null
189
+
190
+ eachLine(file, (line) => {
191
+ if (!line.startsWith('{')) return
192
+ let o
193
+ try {
194
+ o = JSON.parse(line)
195
+ } catch {
196
+ return
197
+ }
198
+
199
+ // The title Claude Code has already written for itself: one set by hand wins.
200
+ if (o.type === 'ai-title' && o.aiTitle) {
201
+ aiTitle = o.aiTitle
202
+ return
203
+ }
204
+ if (o.type === 'custom-title' && o.customTitle) {
205
+ custom = o.customTitle
206
+ return
207
+ }
208
+
209
+ if (o.timestamp) {
210
+ const t = Date.parse(o.timestamp)
211
+ if (t) {
212
+ if (!out.started_at || t < out.started_at) out.started_at = t
213
+ if (!out.ended_at || t > out.ended_at) out.ended_at = t
214
+ }
215
+ }
216
+
217
+ // Subagents talk among themselves: that is not the conversation with you.
218
+ if (o.isSidechain) return
219
+
220
+ if (o.type === 'user') {
221
+ const t = clean(textOf(o))
222
+ if (!t || NOISE.test(t)) return
223
+ out.turns++
224
+ if (!out.first_prompt) out.first_prompt = clip(t, CLIP_FIRST)
225
+ out.recent_prompts.push(clip(t, CLIP_RECENT))
226
+ if (out.recent_prompts.length > RECENT) out.recent_prompts.shift()
227
+ return
228
+ }
229
+
230
+ if (o.type === 'assistant') {
231
+ const t = clean(textOf(o))
232
+ if (t) out.last_reply = clip(t, CLIP_REPLY)
233
+ }
234
+ })
235
+
236
+ out.title_hint = custom || aiTitle || null
237
+ return out
238
+ }
239
+
240
+ /**
241
+ * The sessions worth offering as cards, most recent first.
242
+ *
243
+ * Two passes, because the second one is expensive: first the head of every transcript in the
244
+ * window is checked, to keep only the real sessions and work out which repository they belong
245
+ * to; then only the ones that actually fit under the per-repository cap are read in full.
246
+ *
247
+ * `exclude` are the session ids already on a card: re-importing must not make duplicates.
248
+ * `live` are the sessions still running, which can be attached too.
249
+ * `root` only changes in the tests, to look at a directory of fake transcripts.
250
+ */
251
+ export function scanSessions({
252
+ days = 14,
253
+ perRepo = 10,
254
+ exclude = new Set(),
255
+ live = new Set(),
256
+ root = PROJECTS_DIR,
257
+ } = {}) {
258
+ const cutoff = Date.now() - days * 86400000
259
+ const byRepo = new Map()
260
+ const roots = new Map()
261
+
262
+ let dirs
263
+ try {
264
+ dirs = fs.readdirSync(root)
265
+ } catch {
266
+ return [] // no transcripts on this machine: nothing to import
267
+ }
268
+
269
+ for (const dir of dirs) {
270
+ const full = path.join(root, dir)
271
+ let files
272
+ try {
273
+ files = fs.readdirSync(full)
274
+ } catch {
275
+ continue
276
+ }
277
+ for (const name of files) {
278
+ if (!name.endsWith('.jsonl')) continue
279
+ const sessionId = name.slice(0, -'.jsonl'.length)
280
+ if (exclude.has(sessionId)) continue
281
+
282
+ const file = path.join(full, name)
283
+ let st
284
+ try {
285
+ st = fs.statSync(file)
286
+ } catch {
287
+ continue
288
+ }
289
+ if (!st.size || st.mtimeMs < cutoff) continue
290
+
291
+ // Two reads of 64 KB at most, head and tail, and the tail only if it is needed.
292
+ const head = slice(file, 0, HEAD)
293
+ let tailText = null
294
+ const tail = () => (tailText ??= st.size > HEAD ? slice(file, st.size - HEAD, HEAD) : '')
295
+
296
+ if (!MODE.test(head) && !MODE.test(tail())) continue
297
+
298
+ // Which directory it was running in. Always read from the transcript, never by
299
+ // reversing the directory name: that name is lossy — `my_project` and `my-project`
300
+ // become the same slug — and there is no way back from it.
301
+ const cwd = firstCwd(head) ?? firstCwd(tail())
302
+ if (!cwd) continue
303
+ const repo = repoOf(cwd, roots)
304
+ if (!repo) continue // the repository is gone: the session cannot be resumed
305
+
306
+ const list = byRepo.get(repo)
307
+ if (list) list.push({ sessionId, file, cwd, mtime: st.mtimeMs })
308
+ else byRepo.set(repo, [{ sessionId, file, cwd, mtime: st.mtimeMs }])
309
+ }
310
+ }
311
+
312
+ const out = []
313
+ for (const [repo, list] of byRepo) {
314
+ list.sort((a, b) => b.mtime - a.mtime)
315
+ let taken = 0
316
+ for (const c of list) {
317
+ if (taken >= perRepo) break
318
+ const d = digest(c.file)
319
+ // Not one word of yours in the transcript: there is nothing for a card to say.
320
+ if (!d.turns) continue
321
+ taken++
322
+ out.push({
323
+ session_id: c.sessionId,
324
+ project_path: repo,
325
+ project_name: path.basename(repo),
326
+ cwd: c.cwd,
327
+ alive: live.has(c.sessionId),
328
+ started_at: d.started_at ?? c.mtime,
329
+ ended_at: d.ended_at ?? c.mtime,
330
+ turns: d.turns,
331
+ title_hint: d.title_hint,
332
+ first_prompt: d.first_prompt,
333
+ recent_prompts: d.recent_prompts,
334
+ last_reply: d.last_reply,
335
+ })
336
+ }
337
+ }
338
+
339
+ out.sort((a, b) => b.ended_at - a.ended_at)
340
+ return out
341
+ }