k0-board 0.4.0 → 0.6.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/LICENSE +83 -18
- package/README.md +1141 -119
- package/bin/k0-board.js +127 -12
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +14 -2
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- 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 +5 -2
- 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/cloud.js +355 -0
- package/server/db.js +2117 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1341 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1069 -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/sync.js +383 -0
- package/server/update.js +325 -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/account.js +265 -0
- package/web/awake.js +62 -0
- package/web/base.css +97 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +110 -12
- 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/launcher.js
CHANGED
|
@@ -14,19 +14,113 @@ export function sessionName(title) {
|
|
|
14
14
|
|
|
15
15
|
export const findClaude = () => shell.findClaude()
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* What is under the cursor: the text after the `❯` of the input box, as it reads on screen.
|
|
19
|
+
*
|
|
20
|
+
* The box is the last line with a `❯` on it — the sent messages above it are echoed with the
|
|
21
|
+
* same mark, but they are above it. Null when there is no such line at all: a dialog, the trust
|
|
22
|
+
* question, the interface still drawing. A menu with its `❯` on the chosen row reads as text,
|
|
23
|
+
* which is the right answer, because a key pressed there picks something.
|
|
24
|
+
*/
|
|
25
|
+
export function promptText(screen) {
|
|
26
|
+
if (!screen) return null
|
|
27
|
+
const lines = screen.split('\n')
|
|
28
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
29
|
+
const m = /^\s*❯(.*)$/.exec(lines[i])
|
|
30
|
+
if (m) return m[1].trim()
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Free to write in: nothing after the `❯`, or only the hint Claude Code prints in grey on a box
|
|
37
|
+
* that has never been typed in — `Try "how do I log an error?"` — which the next key replaces.
|
|
38
|
+
*/
|
|
39
|
+
export function promptIsEmpty(screen) {
|
|
40
|
+
const text = promptText(screen)
|
|
41
|
+
return text !== null && (text === '' || text.startsWith('Try "'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long a session is left alone after a rename was typed into it: long enough for the name
|
|
46
|
+
* in its session file to change, which is how the next round knows it took. After a refusal —
|
|
47
|
+
* the window not in front, something under the cursor — it is looked at again sooner.
|
|
48
|
+
*/
|
|
49
|
+
export const RENAME_RETRY_MS = 60000
|
|
50
|
+
export const RENAME_REFUSED_MS = 5000
|
|
51
|
+
const attempted = new Map() // session id -> { name, until }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The same clearing out `forgetDeadSessions` does in watcher.js, for the same reason: a rename
|
|
55
|
+
* attempted against a session that has since ended is a note about nobody, and nothing was ever
|
|
56
|
+
* removing it.
|
|
57
|
+
*/
|
|
58
|
+
export function forgetRenameAttempts(alive) {
|
|
59
|
+
for (const sessionId of attempted.keys()) if (!alive.has(sessionId)) attempted.delete(sessionId)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a live session is waiting to be renamed, and can be right now.
|
|
64
|
+
*
|
|
65
|
+
* Only a session that is sitting idle — the raw status from its session file: no turn running,
|
|
66
|
+
* no dialog open — and only where the platform can type a command into it and read back what
|
|
67
|
+
* it typed. A session file without a `name` is an older Claude Code: k0 could type the command
|
|
68
|
+
* but never see whether it took, and it would go on typing it, so there the name catches up
|
|
69
|
+
* when the session ends.
|
|
70
|
+
*/
|
|
71
|
+
export function renameDue(story, session, now = Date.now()) {
|
|
72
|
+
if (!session || session.status !== 'idle' || !('name' in session)) return false
|
|
73
|
+
if (!story.terminal_window_id || !capabilities.terminal.commands) return false
|
|
74
|
+
const name = sessionName(story.title)
|
|
75
|
+
if (session.name === name) return false
|
|
76
|
+
const last = attempted.get(story.session_id)
|
|
77
|
+
return !(last && last.name === name && last.until > now)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Types `/rename` with the story's name into the session's window — if that window is the one in
|
|
82
|
+
* front of you. The adapter never raises it: the keyboard belongs to whatever you are doing, so
|
|
83
|
+
* the rename waits until you look at the session, and happens then.
|
|
84
|
+
*/
|
|
85
|
+
export async function renameLive(story) {
|
|
86
|
+
const name = sessionName(story.title)
|
|
87
|
+
const line = `/rename ${name}`
|
|
88
|
+
// Written down before the keys go in, not after: the watching loop asks again a second later,
|
|
89
|
+
// and typing takes longer than that — two rounds typing the same command into one box is a
|
|
90
|
+
// box with the command in it twice.
|
|
91
|
+
attempted.set(story.session_id, { name, until: Date.now() + RENAME_REFUSED_MS })
|
|
92
|
+
const res = await terminal.command(line, story.terminal_window_id, {
|
|
93
|
+
ready: promptIsEmpty,
|
|
94
|
+
verify: (screen) => promptText(screen) === line,
|
|
95
|
+
})
|
|
96
|
+
attempted.set(story.session_id, { name, until: Date.now() + (res.sent ? RENAME_RETRY_MS : RENAME_REFUSED_MS) })
|
|
97
|
+
return { ...res, name }
|
|
98
|
+
}
|
|
99
|
+
|
|
17
100
|
/** In driving mode the terminal has to be readable from across the room. */
|
|
18
101
|
const DRIVING_FONT_SIZE = 22
|
|
19
102
|
|
|
103
|
+
/** And a window worth glancing at from there takes the whole screen, not its usual share of it. */
|
|
104
|
+
const DRIVING_COVERAGE = 1
|
|
105
|
+
|
|
20
106
|
/** How big the text has to be right now: it depends only on the mode in force. */
|
|
21
107
|
const fontSize = () => (isDriving() ? Promise.resolve(DRIVING_FONT_SIZE) : terminal.defaultFontSize())
|
|
22
108
|
|
|
23
|
-
|
|
24
|
-
|
|
109
|
+
/** And how much screen the window takes. Undefined means "your usual share", whatever it is. */
|
|
110
|
+
const coverage = () => (isDriving() ? DRIVING_COVERAGE : undefined)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Puts every window k0 owns the way the mode in force wants it, text and size in one gesture.
|
|
114
|
+
* It runs on every mode change and once more at startup, which is what repairs the windows a
|
|
115
|
+
* server restart left behind at the wrong size.
|
|
116
|
+
*/
|
|
117
|
+
export async function applyModeToWindows(handles) {
|
|
118
|
+
return terminal.applyMode(handles, { fontSize: await fontSize(), coverage: coverage() })
|
|
25
119
|
}
|
|
26
120
|
|
|
27
|
-
export const relayoutWindows = (handles) => terminal.relayout(handles)
|
|
121
|
+
export const relayoutWindows = (handles) => terminal.relayout(handles, { coverage: coverage() })
|
|
28
122
|
export const setWindowTitle = (handle, title) => terminal.setTitle(handle, title)
|
|
29
|
-
export const focusWindow = (handle) => terminal.focus(handle)
|
|
123
|
+
export const focusWindow = (handle, title) => terminal.focus(handle, title)
|
|
30
124
|
export const closeTerminal = ({ winId, pid }) => terminal.close({ handle: winId, pid })
|
|
31
125
|
|
|
32
126
|
/** Waits for Claude to really be up: the session shows up in ~/.claude/sessions. */
|
|
@@ -69,11 +163,25 @@ async function waitForPrompt(handle, timeoutMs = 15000) {
|
|
|
69
163
|
}
|
|
70
164
|
|
|
71
165
|
/**
|
|
72
|
-
*
|
|
166
|
+
* What every session is started with, new or resumed.
|
|
167
|
+
*
|
|
168
|
+
* Claude Code decides when it starts, model by model, whether a session gets its task tools —
|
|
169
|
+
* TaskCreate, TaskUpdate and the rest — and a newer model such as Opus 5 does not get them unless
|
|
170
|
+
* this is in the environment. The commands k0 ships keep a task per step of the work, so without
|
|
171
|
+
* it a session is told to do something it has no tool for, and nothing warns: the call is refused
|
|
172
|
+
* and the model carries on without. It is read at startup, which is why a resume carries it too.
|
|
173
|
+
*/
|
|
174
|
+
export const SESSION_ENV = { CLAUDE_CODE_ENABLE_TODO_TOOLS: '1' }
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Starts (or resumes) a story's session.
|
|
73
178
|
* mode: 'start' assigns a new session id, 'resume' reopens the existing one.
|
|
179
|
+
*
|
|
180
|
+
* `story` is the flat row `db.listStories()` hands over, session and all: `auto_send` lives on
|
|
181
|
+
* the session now, and it is read from there through the same row rather than looked up again.
|
|
74
182
|
*/
|
|
75
|
-
export async function launch({
|
|
76
|
-
const name = sessionName(
|
|
183
|
+
export async function launch({ story, sessionId, mode = 'start' }) {
|
|
184
|
+
const name = sessionName(story.title)
|
|
77
185
|
const bin = findClaude()
|
|
78
186
|
const resume = mode === 'resume'
|
|
79
187
|
const args = []
|
|
@@ -97,11 +205,11 @@ export async function launch({ card, sessionId, mode = 'start' }) {
|
|
|
97
205
|
args.push('-n', name)
|
|
98
206
|
// With "send it for me" the prompt goes straight to the CLI and starts without touching
|
|
99
207
|
// anything.
|
|
100
|
-
const autoSend = mode === 'start' &&
|
|
101
|
-
if (autoSend) args.push(
|
|
208
|
+
const autoSend = mode === 'start' && story.auto_send && story.prompt?.trim()
|
|
209
|
+
if (autoSend) args.push(story.prompt.trim())
|
|
102
210
|
|
|
103
|
-
const command = terminal.buildCommand({ cwd:
|
|
104
|
-
const winId = await terminal.open({ command, title: name, fontSize: await fontSize() })
|
|
211
|
+
const command = terminal.buildCommand({ cwd: story.project_path, bin, args, env: SESSION_ENV })
|
|
212
|
+
const winId = await terminal.open({ command, title: name, fontSize: await fontSize(), coverage: coverage() })
|
|
105
213
|
const up = await waitForSession(sessionId)
|
|
106
214
|
|
|
107
215
|
if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
|
|
@@ -109,17 +217,17 @@ export async function launch({ card, sessionId, mode = 'start' }) {
|
|
|
109
217
|
// ago, and putting it back under the cursor is at best a stale instruction in the way — at
|
|
110
218
|
// worst it is sent, because where pasting is unavailable the fallback below types it. This
|
|
111
219
|
// matters more now that k0 closes forgotten terminals by itself: a Resume is no longer rare.
|
|
112
|
-
if (resume || !up || !
|
|
220
|
+
if (resume || !up || !story.prompt?.trim()) return { name, up, winId, pasted: false }
|
|
113
221
|
|
|
114
222
|
await waitForPrompt(winId)
|
|
115
223
|
|
|
116
224
|
if (capabilities.terminal.pasteWithoutSending) {
|
|
117
|
-
const res = await terminal.paste(
|
|
225
|
+
const res = await terminal.paste(story.prompt.trim(), winId)
|
|
118
226
|
if (res.pasted) return { name, up, winId, ...res }
|
|
119
227
|
}
|
|
120
228
|
|
|
121
229
|
// Pasting is unavailable or was refused — on macOS that means no Accessibility permission.
|
|
122
230
|
// We type it instead, which sends it.
|
|
123
|
-
const fallback = await terminal.type(
|
|
231
|
+
const fallback = await terminal.type(story.prompt.trim(), winId)
|
|
124
232
|
return { name, up, winId, pasted: false, autoSent: fallback.written, ...fallback }
|
|
125
233
|
}
|
package/server/machine.js
CHANGED
|
@@ -10,7 +10,7 @@ export { parsePs, cpuSeconds } from '../platform/shared/ps.js'
|
|
|
10
10
|
// The honest number for "what does this session cost" is not the `claude` process: it is its
|
|
11
11
|
// whole subtree. A 200 MB claude can have an MCP server under it with Chrome behind that, and
|
|
12
12
|
// a 200 MB `tsc` started from a commit hook. That is where the memory goes, and it is what no
|
|
13
|
-
// other tool can attribute to the right
|
|
13
|
+
// other tool can attribute to the right story — Activity Monitor has never heard of stories.
|
|
14
14
|
//
|
|
15
15
|
// Summing the resident memory of a subtree counts shared memory twice between parent and
|
|
16
16
|
// children, so the total is a little generous. It is the usual approximation, and it is the
|
|
@@ -155,6 +155,15 @@ export function loadOf(pid) {
|
|
|
155
155
|
return { rss, cpu: snap.cpu === null ? null : cpu / CORES, procs: pids.size, top: heaviest(byName) }
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* What k0 itself weighs, measured the same way as everything else on the board.
|
|
160
|
+
*
|
|
161
|
+
* The chip has always said what the sessions cost and what Chrome costs, and never what the thing
|
|
162
|
+
* doing the measuring costs. That is the one number nobody could see, and for a server that runs
|
|
163
|
+
* under launchd for weeks it is the one that should be hardest to hide from.
|
|
164
|
+
*/
|
|
165
|
+
export const self = () => loadOf(process.pid)
|
|
166
|
+
|
|
158
167
|
/**
|
|
159
168
|
* How the machine is doing, and what is taking it up that is not a k0 session. `others` exists
|
|
160
169
|
* so k0 does not take blame that belongs to Chrome: with swap full, the sessions on their own
|