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
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { alive as isAlive } from '../platform/shared/run.js'
5
+
6
+ const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions')
7
+ export const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects')
8
+
9
+ const SLUG_MAX = 200
10
+
11
+ /**
12
+ * Reproduces exactly the slug Claude Code uses to name the transcript directory: every
13
+ * non-alphanumeric character becomes `-`, and above 200 characters the name is truncated and
14
+ * closed with a hash of the original path.
15
+ * /home/you/my-project -> -home-you-my-project
16
+ */
17
+ export function projectSlug(cwd) {
18
+ const s = cwd.replace(/[^a-zA-Z0-9]/g, '-')
19
+ if (s.length <= SLUG_MAX) return s
20
+ let h = 0
21
+ for (let i = 0; i < cwd.length; i++) h = ((h << 5) - h + cwd.charCodeAt(i)) | 0
22
+ return `${s.slice(0, SLUG_MAX)}-${Math.abs(h).toString(36)}`
23
+ }
24
+
25
+ export function transcriptPath(cwd, sessionId) {
26
+ return path.join(PROJECTS_DIR, projectSlug(cwd), `${sessionId}.jsonl`)
27
+ }
28
+
29
+ /**
30
+ * Changes the name of a session that has already ended, so that renaming a card makes the new
31
+ * name show up in the list of sessions you can resume.
32
+ *
33
+ * These are the two lines Claude Code writes for itself at the end of the transcript on every
34
+ * turn, and the last one wins: all this does is write another copy.
35
+ *
36
+ * Only on a closed session: if the process is alive it holds the file open for appending and
37
+ * would put the old name back on the next turn. There, the `-n` of Resume takes care of it.
38
+ */
39
+ export function renameSession(cwd, sessionId, name) {
40
+ const file = transcriptPath(cwd, sessionId)
41
+ if (!fs.existsSync(file)) return false
42
+ const line = (o) => JSON.stringify({ ...o, sessionId }) + '\n'
43
+ fs.appendFileSync(
44
+ file,
45
+ line({ type: 'custom-title', customTitle: name }) + line({ type: 'agent-name', agentName: name })
46
+ )
47
+ return true
48
+ }
49
+
50
+ /**
51
+ * Reads the live sessions: ~/.claude/sessions/<pid>.json.
52
+ * `status` is busy | shell | idle | waiting; with waiting, `waitingFor` says why.
53
+ */
54
+ export function readLiveSessions() {
55
+ const map = new Map()
56
+ let files
57
+ try {
58
+ files = fs.readdirSync(SESSIONS_DIR)
59
+ } catch {
60
+ return map
61
+ }
62
+ for (const f of files) {
63
+ if (!/^\d+\.json$/.test(f)) continue
64
+ let s
65
+ try {
66
+ s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'))
67
+ } catch {
68
+ continue
69
+ }
70
+ if (!s.sessionId || !isAlive(s.pid)) continue
71
+ map.set(s.sessionId, s)
72
+ }
73
+ return map
74
+ }
75
+
76
+ /**
77
+ * State read from the transcript, kept incrementally: on the first look the whole file is
78
+ * read, then only the new part. It has to work this way because the `permission-mode` line is
79
+ * written when the mode changes, and in a long session it would fall outside any window on
80
+ * the end of the file.
81
+ */
82
+ const scans = new Map() // sessionId -> { offset, permissionMode, pending: Map<id,name>, tail }
83
+
84
+ const WATCHED_TOOLS = new Set(['ExitPlanMode', 'AskUserQuestion'])
85
+
86
+ export function scanTranscript(cwd, sessionId) {
87
+ const file = transcriptPath(cwd, sessionId)
88
+ let scan = scans.get(sessionId)
89
+ if (!scan) {
90
+ scan = { offset: 0, permissionMode: null, pending: new Map(), tail: '' }
91
+ scans.set(sessionId, scan)
92
+ }
93
+
94
+ let size
95
+ try {
96
+ size = fs.statSync(file).size
97
+ } catch {
98
+ return summarise(scan)
99
+ }
100
+ // File recreated or truncated: start over.
101
+ if (size < scan.offset) Object.assign(scan, { offset: 0, permissionMode: null, pending: new Map(), tail: '' })
102
+ if (size === scan.offset) return summarise(scan)
103
+
104
+ const fd = fs.openSync(file, 'r')
105
+ try {
106
+ const len = size - scan.offset
107
+ const buf = Buffer.alloc(len)
108
+ fs.readSync(fd, buf, 0, len, scan.offset)
109
+ scan.offset = size
110
+ const chunk = scan.tail + buf.toString('utf8')
111
+ const lines = chunk.split('\n')
112
+ // The last line may still be half written: we pick it up again next round.
113
+ scan.tail = lines.pop() ?? ''
114
+ for (const line of lines) applyLine(scan, line)
115
+ } finally {
116
+ fs.closeSync(fd)
117
+ }
118
+ return summarise(scan)
119
+ }
120
+
121
+ function applyLine(scan, line) {
122
+ if (!line.trim()) return
123
+ let d
124
+ try {
125
+ d = JSON.parse(line)
126
+ } catch {
127
+ return
128
+ }
129
+ if (d.type === 'permission-mode' && d.permissionMode) {
130
+ scan.permissionMode = d.permissionMode
131
+ return
132
+ }
133
+ const content = d.message?.content
134
+ if (!Array.isArray(content)) return
135
+ for (const c of content) {
136
+ if (c?.type === 'tool_use' && WATCHED_TOOLS.has(c.name)) {
137
+ scan.pending.set(c.id, c.name)
138
+ continue
139
+ }
140
+ if (c?.type !== 'tool_result' || !c.tool_use_id) continue
141
+ const was = scan.pending.get(c.tool_use_id)
142
+ scan.pending.delete(c.tool_use_id)
143
+ // Plan approved: leave plan mode straight away, without waiting for the `permission-mode`
144
+ // line that only arrives on the next turn. If the plan was rejected instead, we stay in
145
+ // planning.
146
+ if (was === 'ExitPlanMode' && typeof c.content === 'string' && c.content.includes('approved your plan')) {
147
+ scan.permissionMode = 'auto'
148
+ }
149
+ }
150
+ }
151
+
152
+ function summarise(scan) {
153
+ const names = [...scan.pending.values()]
154
+ return {
155
+ planMode: scan.permissionMode === 'plan',
156
+ pendingPlan: names.includes('ExitPlanMode'),
157
+ pendingAsk: names.includes('AskUserQuestion'),
158
+ }
159
+ }
160
+
161
+ export function forgetSession(sessionId) {
162
+ scans.delete(sessionId)
163
+ }
164
+
165
+ /**
166
+ * Translates Claude Code's signals into the board's statuses.
167
+ * Returns { status, alive }.
168
+ */
169
+ export function deriveStatus(card, live) {
170
+ if (card.completed_at) return { status: 'COMPLETED', alive: false }
171
+ if (!card.session_id) return { status: 'BACKLOG', alive: false }
172
+
173
+ const session = live.get(card.session_id)
174
+ if (!session) {
175
+ // Process dead: the session can still be resumed. We keep the last status only if it said
176
+ // something about you (an open question, a plan left sitting there); WORKING and PLANNING
177
+ // would be a lie, nothing is grinding away any more.
178
+ const stale = card.status === 'WORKING' || card.status === 'PLANNING' || card.status === 'BACKLOG'
179
+ return { status: stale ? 'IDLE' : card.status, alive: false }
180
+ }
181
+
182
+ const t = scanTranscript(session.cwd || card.project_path, card.session_id)
183
+
184
+ switch (session.status) {
185
+ case 'busy':
186
+ return { status: t.planMode ? 'PLANNING' : 'WORKING', alive: true }
187
+
188
+ case 'waiting':
189
+ // While a dialog is open the transcript has not yet written the call that caused it
190
+ // (verified: with the plan on screen, there is no trace of ExitPlanMode). The
191
+ // distinction comes from `waitingFor`, which the session file writes immediately:
192
+ // "input needed" is a question, "permission prompt" in plan mode is a plan to approve.
193
+ if (session.waitingFor === 'input needed') return { status: 'ASK', alive: true }
194
+ if (t.planMode || t.pendingPlan) return { status: 'PLANNED', alive: true }
195
+ return { status: 'ASK', alive: true }
196
+
197
+ default: // idle | shell — no dialog open, the ball is in your court
198
+ return { status: 'IDLE', alive: true }
199
+ }
200
+ }
package/web/base.css ADDED
@@ -0,0 +1,134 @@
1
+ /* Type and scale: the system font, 17px base, with Caveat for the handwritten touches only.
2
+ The house variables live here, and they live here because there are two pages: the board
3
+ and the file viewer. If you change a colour, change it once — this is the place.
4
+
5
+ The font is bundled rather than linked. k0 is a local tool and should stay one: a link to a
6
+ font service would mean a request to a third party every time you open the board, and a
7
+ board that comes up wrong on a laptop with no connection. See web/fonts/README.md. */
8
+ @font-face {
9
+ font-family: 'Caveat';
10
+ font-style: normal;
11
+ font-weight: 500 600;
12
+ font-display: swap;
13
+ src: url('fonts/caveat-latin.woff2') format('woff2');
14
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
15
+ U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
16
+ }
17
+ @font-face {
18
+ font-family: 'Caveat';
19
+ font-style: normal;
20
+ font-weight: 500 600;
21
+ font-display: swap;
22
+ src: url('fonts/caveat-latin-ext.woff2') format('woff2');
23
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
24
+ U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
25
+ U+2113, U+2C60-2C7F, U+A720-A7FF;
26
+ }
27
+
28
+ :root {
29
+ --bg: #f3f1ea;
30
+ --ink: #2b2b2b;
31
+ --muted: #7d7869;
32
+ --line: #ddd8cb;
33
+ --paper: #fffdf7;
34
+
35
+ /* One colour per status: the whole card is what tells you where you are. */
36
+ --ASK: #fbb4b4;
37
+ --PLANNED: #fdd0a2;
38
+ --IDLE: #fde68a;
39
+ --WORKING: #a9c9f5;
40
+ --PLANNING: #c9dbfb;
41
+ --BACKLOG: #e2dfd6;
42
+ --COMPLETED: #b7e8c4;
43
+
44
+ /* The two signals: red if something is outstanding, green if everything is safe.
45
+ The red is the house red, the same one the bin and Delete use. */
46
+ --alert: #b91c1c;
47
+ --calm: #15803d;
48
+ --warn: #b45309;
49
+
50
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
51
+ --hand: 'Caveat', ui-sans-serif, system-ui, sans-serif;
52
+ --mono: ui-monospace, 'SF Mono', Menlo, monospace;
53
+ }
54
+
55
+ * { box-sizing: border-box; }
56
+
57
+ html { font-size: 17px; }
58
+ html, body { height: 100%; }
59
+
60
+ /* ── Buttons and dialogs ─────────────────────────────────────────────────────
61
+ They live here, and not on the board where they were born, because both pages open
62
+ dialogs: the card editor over there, the paste-a-chunk-of-chat box over here. A dialog
63
+ without its buttons would be half a dialog, so they come together. */
64
+ button {
65
+ font: inherit;
66
+ border: 1px solid var(--line);
67
+ background: #fffdf7;
68
+ border-radius: 8px;
69
+ padding: 8px 15px;
70
+ cursor: pointer;
71
+ }
72
+ button:hover { background: #fff; border-color: #c9c3b4; }
73
+ button.primary { background: var(--ink); color: #fffdf7; border-color: var(--ink); font-weight: 600; }
74
+ button.danger { color: #b91c1c; border-color: #f1c9c9; }
75
+ button.destructive { background: #b91c1c; border-color: #b91c1c; color: #fffdf7; font-weight: 600; }
76
+ button.destructive:hover { background: #a11717; border-color: #a11717; }
77
+
78
+ dialog {
79
+ border: none;
80
+ border-radius: 14px;
81
+ padding: 26px;
82
+ width: min(560px, 93vw);
83
+ background: #fffdf7;
84
+ color: var(--ink);
85
+ font-family: var(--sans);
86
+ font-size: 1rem;
87
+ box-shadow: 0 20px 60px rgba(0, 0, 0, .3);
88
+ }
89
+ dialog::backdrop { background: rgba(40, 38, 32, .42); }
90
+ dialog h2 { margin: 0; font: 700 1.3rem/1 var(--sans); }
91
+
92
+ /* The X sits top right, where you expect to find it: closing without saving is a way out,
93
+ not a choice to line up next to the buttons that do something. */
94
+ dialog .head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
95
+ dialog .x {
96
+ flex: none;
97
+ padding: 0;
98
+ width: 30px;
99
+ height: 30px;
100
+ margin: -4px -4px 0 0;
101
+ border: none;
102
+ background: none;
103
+ border-radius: 999px;
104
+ color: var(--muted);
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ }
109
+ dialog .x svg { width: 17px; height: 17px; display: block; }
110
+ dialog .x:hover { background: #f0ede3; color: var(--ink); border: none; }
111
+ dialog label { display: block; margin-bottom: 18px; font-size: .82rem; font-weight: 600; color: var(--muted); }
112
+ dialog label small { display: block; margin-top: 5px; font-weight: 400; }
113
+ dialog input, dialog textarea {
114
+ display: block;
115
+ width: 100%;
116
+ margin-top: 6px;
117
+ font: inherit;
118
+ font-size: 1rem;
119
+ color: var(--ink);
120
+ padding: 10px 12px;
121
+ border: 1px solid var(--line);
122
+ border-radius: 8px;
123
+ background: #fff;
124
+ }
125
+ dialog textarea { resize: vertical; line-height: 1.5; }
126
+ /* A repository typed but never picked from the list: it does not exist, and it shows. */
127
+ dialog input.bad { border-color: #d97070; background: #fff6f6; }
128
+ dialog footer { display: flex; gap: 8px; align-items: center; margin-top: 4px; }
129
+ dialog .spacer { flex: 1; }
130
+
131
+ /* The house confirmation: only ever asked before throwing a card away. */
132
+ dialog.ask { width: min(380px, 88vw); padding: 22px 24px 18px; }
133
+ dialog.ask p { margin: 0 0 20px; font-size: 1rem; }
134
+ dialog.ask footer { margin: 0; }