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.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- 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; }
|